diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index e95e8a6766..f8831c9dd4 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -1871,10 +1871,6 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot): 'margin', 'aspect', 'data_aspect', 'frame_width', 'frame_height', 'responsive'] - def __init__(self, overlay, **params): - super(OverlayPlot, self).__init__(overlay, **params) - self.set_root(params.pop('root', None)) - def _process_legend(self): plot = self.handles['plot'] subplots = self.traverse(lambda x: x, [lambda x: x is not self]) diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index 8e198ee587..28412eb85a 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -12,17 +12,17 @@ from ...core import ( OrderedDict, Store, AdjointLayout, NdLayout, Layout, Empty, - GridSpace, HoloMap, Element, DynamicMap + GridSpace, HoloMap, Element ) from ...core.options import SkipRendering from ...core.util import ( basestring, cftime_to_timestamp, cftime_types, get_method_owner, unique_iterator, wrap_tuple, wrap_tuple_streams, _STANDARD_CALENDARS) -from ...streams import Stream from ..links import Link from ..plot import ( DimensionedPlot, GenericCompositePlot, GenericLayoutPlot, - GenericElementPlot, GenericOverlayPlot, GenericAdjointLayoutPlot + GenericElementPlot, GenericOverlayPlot, GenericAdjointLayoutPlot, + CallbackPlot ) from ..util import attach_streams, displayable, collate from .callbacks import LinkCallback @@ -31,7 +31,7 @@ empty_plot, decode_bytes, theme_attr_json, cds_column_replace) -class BokehPlot(DimensionedPlot): +class BokehPlot(DimensionedPlot, CallbackPlot): """ Plotting baseclass for the Bokeh backends, implementing the basic plotting interface for Bokeh based plots. @@ -91,60 +91,6 @@ def get_data(self, element, ranges, style): raise NotImplementedError - @property - def link_sources(self): - "Returns potential Link or Stream sources." - if isinstance(self, GenericOverlayPlot): - zorders = [] - elif self.batched: - zorders = list(range(self.zorder, self.zorder+len(self.hmap.last))) - else: - zorders = [self.zorder] - - if isinstance(self, GenericOverlayPlot) and not self.batched: - sources = [] - elif not self.static or isinstance(self.hmap, DynamicMap): - sources = [o for i, inputs in self.stream_sources.items() - for o in inputs if i in zorders] - else: - sources = [self.hmap.last] - return sources - - - def _construct_callbacks(self): - """ - Initializes any callbacks for streams which have defined - the plotted object as a source. - """ - cb_classes = set() - registry = list(Stream.registry.items()) - callbacks = Stream._callbacks['bokeh'] - for source in self.link_sources: - streams = [ - s for src, streams in registry for s in streams - if src is source or (src._plot_id is not None and - src._plot_id == source._plot_id)] - cb_classes |= {(callbacks[type(stream)], stream) for stream in streams - if type(stream) in callbacks and stream.linked - and stream.source is not None} - cbs = [] - sorted_cbs = sorted(cb_classes, key=lambda x: id(x[0])) - for cb, group in groupby(sorted_cbs, lambda x: x[0]): - cb_streams = [s for _, s in group] - cbs.append(cb(self, cb_streams, source)) - return cbs - - - def set_root(self, root): - """ - Sets the root model on all subplots. - """ - if root is None: - return - for plot in self.traverse(lambda x: x): - plot._root = root - - def _init_datasource(self, data): """ Initializes a data source to be passed into the bokeh glyph. @@ -263,9 +209,6 @@ def cleanup(self): if get_method_owner(subscriber) not in plots ] - if self.comm and self.root is self.handles.get('plot'): - self.comm.close() - def _fontsize(self, key, label='fontsize', common=True): """ @@ -510,10 +453,7 @@ def __init__(self, layout, ranges=None, layout_num=1, keys=None, **params): ranges=ranges, keys=keys, **params) self.cols, self.rows = layout.shape self.subplots, self.layout = self._create_subplots(layout, ranges) - self.set_root(params.pop('root', None)) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) if 'axis_offset' in params: @@ -721,10 +661,7 @@ class LayoutPlot(CompositePlot, GenericLayoutPlot): def __init__(self, layout, keys=None, **params): super(LayoutPlot, self).__init__(layout, keys=keys, **params) self.layout, self.subplots, self.paths = self._init_layout(layout) - self.set_root(params.pop('root', None)) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) diff --git a/holoviews/plotting/mpl/plot.py b/holoviews/plotting/mpl/plot.py index f57d71667d..024d94fe0b 100644 --- a/holoviews/plotting/mpl/plot.py +++ b/holoviews/plotting/mpl/plot.py @@ -350,8 +350,6 @@ def __init__(self, layout, axis=None, create_axes=True, ranges=None, self.subplots, self.subaxes, self.layout = self._create_subplots(layout, axis, ranges, create_axes) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -767,8 +765,6 @@ def __init__(self, layout, keys=None, **params): with mpl.rc_context(rc=self.fig_rcparams): self.subplots, self.subaxes, self.layout = self._compute_gridspec(layout) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) diff --git a/holoviews/plotting/mpl/raster.py b/holoviews/plotting/mpl/raster.py index cbbc8b9e0a..bf30cb4bc4 100644 --- a/holoviews/plotting/mpl/raster.py +++ b/holoviews/plotting/mpl/raster.py @@ -225,8 +225,6 @@ def __init__(self, layout, keys=None, dimensions=None, create_axes=False, ranges if top_level: dimensions, keys = traversal.unique_dimkeys(layout) MPLPlot.__init__(self, dimensions=dimensions, keys=keys, **params) - if top_level: - self.comm = self.init_comm() self.layout = layout self.cyclic_index = 0 diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index c248129771..d01c024e87 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -27,6 +27,7 @@ from ..core.spaces import HoloMap, DynamicMap from ..core.util import stream_parameters, isfinite from ..element import Table, Graph, Contours +from ..streams import Stream from ..util.transform import dim from .util import (get_dynamic_mode, initialize_unbounded, dim_axis_label, attach_streams, traverse_setter, get_nested_streams, @@ -41,6 +42,8 @@ class Plot(param.Parameterized): general enough to use any plotting package or backend. """ + backend = None + # A list of style options that may be supplied to the plotting # call style_opts = [] @@ -48,6 +51,19 @@ class Plot(param.Parameterized): # Use this list to disable any invalid style options _disabled_opts = [] + def __init__(self, renderer=None, root=None, **params): + params = {k: v for k, v in params.items() + if k in self.params()} + super(Plot, self).__init__(**params) + self.renderer = renderer if renderer else Store.renderers[self.backend].instance() + self._force = False + self._comm = None + self._document = None + self._root = None + self._pane = None + self.set_root(root) + + @property def state(self): """ @@ -56,6 +72,17 @@ def state(self): """ raise NotImplementedError + + def set_root(self, root): + """ + Sets the root model on all subplots. + """ + if root is None: + return + for plot in self.traverse(lambda x: x): + plot._root = root + + @property def root(self): if self._root: @@ -65,6 +92,7 @@ def root(self): else: return None + @property def document(self): return self._document @@ -88,6 +116,33 @@ def document(self, doc): plot.document = doc + @property + def pane(self): + return self._pane + + @pane.setter + def pane(self, pane): + self._pane = pane + if self.subplots: + for plot in self.subplots.values(): + if plot is not None: + plot.pane = pane + + + @property + def comm(self): + return self._comm + + + @comm.setter + def comm(self, comm): + self._comm = comm + if self.subplots: + for plot in self.subplots.values(): + if plot is not None: + plot.comm = comm + + def initialize_plot(self, ranges=None): """ Initialize the matplotlib figure. @@ -117,8 +172,6 @@ def cleanup(self): stream._subscribers = [ (p, subscriber) for p, subscriber in stream._subscribers if util.get_method_owner(subscriber) not in plots] - if self.comm: - self.comm.close() def _session_destroy(self, session_context): @@ -150,7 +203,7 @@ def refresh(self, **kwargs): stream_key = util.wrap_tuple_streams(key, self.dimensions, self.streams) self._trigger_refresh(stream_key) - if self.comm is not None and self.top_level: + if self.top_level: self.push() @@ -166,30 +219,19 @@ def push(self): Pushes plot updates to the frontend. """ root = self._root - if (root and self._pane is not None and - root.ref['id'] in self._pane._plots): - child_pane = self._pane._plots[root.ref['id']][1] + if (root and self.pane is not None and + root.ref['id'] in self.pane._plots): + child_pane = self.pane._plots[root.ref['id']][1] else: child_pane = None if self.renderer.backend != 'bokeh' and child_pane is not None: child_pane.object = self.state - elif self.renderer.mode != 'server' or (root and 'embedded' in root.tags): + elif ((self.renderer.mode != 'server' or (root and 'embedded' in root.tags)) + and self.document and self.comm): push(self.document, self.comm) - def init_comm(self): - """ - Initializes comm and attaches streams. - """ - if self.comm: - return self.comm - comm = None - if self.dynamic or self.renderer.widget_mode == 'live': - comm = self.renderer.comm_manager.get_server_comm() - return comm - - @property def id(self): return self.comm.id if self.comm else id(self.state) @@ -331,8 +373,7 @@ class DimensionedPlot(Plot): def __init__(self, keys=None, dimensions=None, layout_dimensions=None, uniform=True, subplot=False, adjoined=None, layout_num=0, - style=None, subplots=None, dynamic=False, renderer=None, - comm=None, root=None, pane=None, **params): + style=None, subplots=None, dynamic=False, **params): self.subplots = subplots self.adjoined = adjoined self.dimensions = dimensions @@ -349,15 +390,7 @@ def __init__(self, keys=None, dimensions=None, layout_dimensions=None, self.current_frame = None self.current_key = None self.ranges = {} - self.renderer = renderer if renderer else Store.renderers[self.backend].instance() - self.comm = comm - self._force = False - self._document = None - self._root = root - self._pane = pane self._updated = False # Whether the plot should be marked as updated - params = {k: v for k, v in params.items() - if k in self.params()} super(DimensionedPlot, self).__init__(**params) @@ -734,6 +767,52 @@ def __len__(self): +class CallbackPlot(object): + + def _construct_callbacks(self): + """ + Initializes any callbacks for streams which have defined + the plotted object as a source. + """ + cb_classes = set() + registry = list(Stream.registry.items()) + callbacks = Stream._callbacks[self.backend] + for source in self.link_sources: + streams = [ + s for src, streams in registry for s in streams + if src is source or (src._plot_id is not None and + src._plot_id == source._plot_id)] + cb_classes |= {(callbacks[type(stream)], stream) for stream in streams + if type(stream) in callbacks and stream.linked + and stream.source is not None} + cbs = [] + sorted_cbs = sorted(cb_classes, key=lambda x: id(x[0])) + for cb, group in groupby(sorted_cbs, lambda x: x[0]): + cb_streams = [s for _, s in group] + cbs.append(cb(self, cb_streams, source)) + return cbs + + @property + def link_sources(self): + "Returns potential Link or Stream sources." + if isinstance(self, GenericOverlayPlot): + zorders = [] + elif self.batched: + zorders = list(range(self.zorder, self.zorder+len(self.hmap.last))) + else: + zorders = [self.zorder] + + if isinstance(self, GenericOverlayPlot) and not self.batched: + sources = [] + elif not self.static or isinstance(self.hmap, DynamicMap): + sources = [o for i, inputs in self.stream_sources.items() + for o in inputs if i in zorders] + else: + sources = [self.hmap.last] + return sources + + + class GenericElementPlot(DimensionedPlot): """ Plotting baseclass to render contents of an Element. Implements @@ -909,9 +988,6 @@ def __init__(self, element, keys=None, ranges=None, dimensions=None, dynamic=dynamic, **dict(params, **plot_opts)) self.streams = get_nested_streams(self.hmap) if streams is None else streams - if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) # Attach streams if not overlaid and not a batched ElementPlot if not (self.overlaid or (self.batched and not isinstance(self, GenericOverlayPlot))): @@ -1229,8 +1305,6 @@ def __init__(self, overlay, ranges=None, batched=True, keys=None, group_counter= self.top_level = keys is None self.dynamic_subplots = [] if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) diff --git a/holoviews/plotting/plotly/__init__.py b/holoviews/plotting/plotly/__init__.py index 9003317d72..fbbc2d3cc8 100644 --- a/holoviews/plotting/plotly/__init__.py +++ b/holoviews/plotting/plotly/__init__.py @@ -14,6 +14,7 @@ from .plot import * # noqa (API import) from .stats import * # noqa (API import) from .tabular import * # noqa (API import) +from .callbacks import * # noqa (API import) from ...core.util import LooseVersion, VersionError import plotly diff --git a/holoviews/plotting/plotly/callbacks.py b/holoviews/plotting/plotly/callbacks.py new file mode 100644 index 0000000000..0d136214c6 --- /dev/null +++ b/holoviews/plotting/plotly/callbacks.py @@ -0,0 +1,215 @@ +from weakref import WeakValueDictionary + +from param.parameterized import add_metaclass + +from ...streams import ( + Stream, Selection1D, RangeXY, RangeX, RangeY, BoundsXY, BoundsX, BoundsY +) + +from .util import _trace_to_subplot + + +class PlotlyCallbackMetaClass(type): + """ + Metaclass for PlotlyCallback classes. + + We want each callback class to keep track of all of the instances of the class. + Using a meta class here lets us keep the logic for instance tracking in one place. + """ + + def __init__(cls, name, bases, attrs): + super(PlotlyCallbackMetaClass, cls).__init__(name, bases, attrs) + + # Create weak-value dictionary to hold instances of the class + cls.instances = WeakValueDictionary() + + def __call__(cls, *args, **kwargs): + inst = super(PlotlyCallbackMetaClass, cls).__call__(*args, **kwargs) + + # Store weak reference to the callback instance in the _instances + # WeakValueDictionary. This will allow instances to be garbage collected and + # the references will be automatically removed from the colleciton when this + # happens. + cls.instances[inst.plot.trace_uid] = inst + + return inst + + +@add_metaclass(PlotlyCallbackMetaClass) +class PlotlyCallback(object): + + def __init__(self, plot, streams, source, **params): + self.plot = plot + self.streams = streams + self.source = source + + @classmethod + def update_streams_from_property_update(cls, property_value, fig_dict): + raise NotImplementedError() + + +class Selection1DCallback(PlotlyCallback): + callback_property = "selected_data" + + @classmethod + def update_streams_from_property_update(cls, selected_data, fig_dict): + + traces = fig_dict.get('data', []) + + # build event data and compute which trace UIDs are eligible + # Look up callback with UID + # graph reference and update the streams + point_inds = {} + if selected_data: + for point in selected_data['points']: + point_inds.setdefault(point['curveNumber'], []) + point_inds[point['curveNumber']].append(point['pointNumber']) + + for trace_ind, trace in enumerate(traces): + trace_uid = trace.get('uid', None) + if trace_uid in cls.instances: + cb = cls.instances[trace_uid] + new_index = point_inds.get(trace_ind, []) + for stream in cb.streams: + stream.event(index=new_index) + + +class BoundsCallback(PlotlyCallback): + callback_property = "selected_data" + boundsx = False + boundsy = False + + @classmethod + def update_streams_from_property_update(cls, selected_data, fig_dict): + + traces = fig_dict.get('data', []) + + if not selected_data or 'range' not in selected_data: + # No valid box selection + box = None + else: + # Get x and y axis references + box = selected_data["range"] + axis_refs = list(box) + xref = [ref for ref in axis_refs if ref.startswith('x')][0] + yref = [ref for ref in axis_refs if ref.startswith('y')][0] + + # Process traces + for trace_ind, trace in enumerate(traces): + trace_type = trace.get('type', 'scatter') + trace_uid = trace.get('uid', None) + + if (trace_uid not in cls.instances or + _trace_to_subplot.get(trace_type, None) != ['xaxis', 'yaxis']): + continue + + cb = cls.instances[trace_uid] + + if (box and trace.get('xaxis', 'x') == xref and + trace.get('yaxis', 'y') == yref): + + new_bounds = (box[xref][0], box[yref][0], box[xref][1], box[yref][1]) + + if cls.boundsx and cls.boundsy: + event_kwargs = dict(bounds=new_bounds) + elif cls.boundsx: + event_kwargs = dict(boundsx=(new_bounds[0], new_bounds[2])) + elif cls.boundsy: + event_kwargs = dict(boundsy=(new_bounds[1], new_bounds[3])) + else: + event_kwargs = dict() + + for stream in cb.streams: + stream.event(**event_kwargs) + else: + if cls.boundsx and cls.boundsy: + event_kwargs = dict(bounds=None) + elif cls.boundsx: + event_kwargs = dict(boundsx=None) + elif cls.boundsy: + event_kwargs = dict(boundsy=None) + else: + event_kwargs = dict() + + for stream in cb.streams: + stream.event(**event_kwargs) + + +class BoundsXYCallback(BoundsCallback): + boundsx = True + boundsy = True + + +class BoundsXCallback(BoundsCallback): + boundsx = True + + +class BoundsYCallback(BoundsCallback): + boundsy = True + + +class RangeCallback(PlotlyCallback): + callback_property = "viewport" + x_range = False + y_range = False + + @classmethod + def update_streams_from_property_update(cls, viewport, fig_dict): + + traces = fig_dict.get('data', []) + + # Process traces + for trace_ind, trace in enumerate(traces): + trace_type = trace.get('type', 'scatter') + trace_uid = trace.get('uid', None) + + if (trace_uid not in cls.instances or + _trace_to_subplot.get(trace_type, None) != ['xaxis', 'yaxis']): + continue + + xaxis = trace.get('xaxis', 'x').replace('x', 'xaxis') + yaxis = trace.get('yaxis', 'y').replace('y', 'yaxis') + xprop = '{xaxis}.range'.format(xaxis=xaxis) + yprop = '{yaxis}.range'.format(yaxis=yaxis) + + if not viewport or xprop not in viewport or yprop not in viewport: + x_range = None + y_range = None + else: + x_range = tuple(viewport[xprop]) + y_range = tuple(viewport[yprop]) + + stream_kwargs = {} + if cls.x_range: + stream_kwargs['x_range'] = x_range + + if cls.y_range: + stream_kwargs['y_range'] = y_range + + cb = cls.instances[trace_uid] + for stream in cb.streams: + stream.event(**stream_kwargs) + + +class RangeXYCallback(RangeCallback): + x_range = True + y_range = True + + +class RangeXCallback(RangeCallback): + x_range = True + + +class RangeYCallback(RangeCallback): + y_range = True + + +callbacks = Stream._callbacks['plotly'] +callbacks[Selection1D] = Selection1DCallback +callbacks[BoundsXY] = BoundsXYCallback +callbacks[BoundsX] = BoundsXCallback +callbacks[BoundsY] = BoundsYCallback +callbacks[RangeXY] = RangeXYCallback +callbacks[RangeX] = RangeXCallback +callbacks[RangeY] = RangeYCallback + diff --git a/holoviews/plotting/plotly/element.py b/holoviews/plotting/plotly/element.py index 09aa0b5716..ed47e16c3a 100644 --- a/holoviews/plotting/plotly/element.py +++ b/holoviews/plotting/plotly/element.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, unicode_literals +import uuid import numpy as np import param @@ -100,6 +101,13 @@ class ElementPlot(PlotlyPlot, GenericElementPlot): # Declare which styles cannot be mapped to a non-scalar dimension _nonvectorized_styles = [] + def __init__(self, element, plot=None, **params): + super(ElementPlot, self).__init__(element, **params) + self.trace_uid = str(uuid.uuid4()) + self.static = len(self.hmap) == 1 and len(self.keys) == len(self.hmap) + self.callbacks = self._construct_callbacks() + + def initialize_plot(self, ranges=None): """ Initializes a new plot object with the last available frame. @@ -138,6 +146,13 @@ def generate_plot(self, key, ranges, element=None): # Initialize traces traces = self.init_graph(d, opts, index=i) graphs.extend(traces) + + if i == 0: + # Associate element with trace.uid property of the first + # plotly trace that is used to render the element. This is + # used to associate the element with the trace during callbacks + traces[0]['uid'] = self.trace_uid + self.handles['graphs'] = graphs # Initialize layout @@ -147,6 +162,7 @@ def generate_plot(self, key, ranges, element=None): # Create figure and return it self.drawn = True fig = dict(data=graphs, layout=layout) + self.handles['fig'] = fig return fig @@ -277,7 +293,7 @@ def init_layout(self, key, element, ranges): else: l, b, z0, r, t, z1 = extent - options = {} + options = {'uirevision': True} dims = self._get_axis_dims(el) if len(dims) > 2: @@ -492,6 +508,7 @@ def generate_plot(self, key, ranges, element=None): layout = self.init_layout(key, element, ranges) figure['layout'].update(layout) self.drawn = True + self.handles['fig'] = figure return figure diff --git a/holoviews/plotting/plotly/plot.py b/holoviews/plotting/plotly/plot.py index e2200a3e6e..12e6abc782 100644 --- a/holoviews/plotting/plotly/plot.py +++ b/holoviews/plotting/plotly/plot.py @@ -10,11 +10,11 @@ from ...core.util import wrap_tuple from ..plot import ( DimensionedPlot, GenericLayoutPlot, GenericCompositePlot, - GenericElementPlot, GenericAdjointLayoutPlot) + GenericElementPlot, GenericAdjointLayoutPlot, CallbackPlot) from .util import figure_grid -class PlotlyPlot(DimensionedPlot): +class PlotlyPlot(DimensionedPlot, CallbackPlot): backend = 'plotly' @@ -48,7 +48,6 @@ def update_frame(self, key, ranges=None): return self.generate_plot(key, ranges) - class LayoutPlot(PlotlyPlot, GenericLayoutPlot): hspacing = param.Number(default=0.15, bounds=(0, 1)) @@ -60,8 +59,6 @@ def __init__(self, layout, **params): self.layout, self.subplots, self.paths = self._init_layout(layout) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -238,6 +235,7 @@ def generate_plot(self, key, ranges=None): title=self._format_title(key)) self.drawn = True + self.handles['fig'] = fig return self.handles['fig'] @@ -301,8 +299,6 @@ def __init__(self, layout, ranges=None, layout_num=1, **params): self.subplots, self.layout = self._create_subplots(layout, ranges) if self.top_level: - self.comm = self.init_comm() - self.traverse(lambda x: setattr(x, 'comm', self.comm)) self.traverse(lambda x: attach_streams(self, x.hmap, 2), [GenericElementPlot]) @@ -371,6 +367,7 @@ def generate_plot(self, key, ranges=None): title=self._format_title(key)) self.drawn = True + self.handles['fig'] = fig return self.handles['fig'] diff --git a/holoviews/plotting/plotly/renderer.py b/holoviews/plotting/plotly/renderer.py index 1ba2d3e970..1b201bd1cb 100644 --- a/holoviews/plotting/plotly/renderer.py +++ b/holoviews/plotting/plotly/renderer.py @@ -3,12 +3,30 @@ import base64 import param +import panel as pn + with param.logging_level('CRITICAL'): import plotly.graph_objs as go from ..renderer import Renderer, MIME_TYPES, HTML_TAGS from ...core.options import Store from ...core import HoloMap +from .callbacks import callbacks + + +def _PlotlyHoloviewsPane(fig_dict): + """ + Custom Plotly pane constructor for use by the HoloViews Pane. + """ + plotly_pane = pn.pane.Plotly(fig_dict, viewport_update_policy='mouseup') + + # Register callbacks on pane + for callback_cls in callbacks.values(): + plotly_pane.param.watch( + lambda event, cls=callback_cls: cls.update_streams_from_property_update(event.new, event.obj.object), + callback_cls.callback_property, + ) + return plotly_pane class PlotlyRenderer(Renderer): @@ -25,6 +43,7 @@ class PlotlyRenderer(Renderer): widgets = ['scrubber', 'widgets'] _loaded = False + _render_with_panel = True def _figure_data(self, plot, fmt, as_script=False, **kwargs): @@ -38,7 +57,7 @@ def _figure_data(self, plot, fmt, as_script=False, **kwargs): if fmt == 'svg': data = data.decode('utf-8') - + if as_script: b64 = base64.b64encode(data).decode("utf-8") (mime_type, tag) = MIME_TYPES[fmt], HTML_TAGS[fmt] @@ -50,6 +69,7 @@ def _figure_data(self, plot, fmt, as_script=False, **kwargs): else: raise ValueError("Unsupported format: {fmt}".format(fmt=fmt)) + @classmethod def plot_options(cls, obj, percent_size): factor = percent_size / 100.0 @@ -68,3 +88,10 @@ def load_nb(cls, inline=True): """ import panel.models.plotly # noqa cls._loaded = True + + +def _activate_plotly_backend(renderer): + if renderer == "plotly": + pn.pane.HoloViews._panes["plotly"] = _PlotlyHoloviewsPane + +Store._backend_switch_hooks.append(_activate_plotly_backend) diff --git a/holoviews/plotting/renderer.py b/holoviews/plotting/renderer.py index 3cdd2c4994..d9fd033fe4 100644 --- a/holoviews/plotting/renderer.py +++ b/holoviews/plotting/renderer.py @@ -16,6 +16,7 @@ from panel import config from panel.io.notebook import load_notebook, render_model, render_mimebundle +from panel.io.state import state from panel.pane import HoloViews as HoloViewsPane from panel.widgets.player import PlayerBase from panel.viewable import Viewable @@ -185,7 +186,7 @@ def __call__(self, obj, fmt='auto', **kwargs): @bothmethod - def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs): + def get_plot(self_or_cls, obj, doc=None, renderer=None, comm=None, **kwargs): """ Given a HoloViews Viewable return a corresponding plot instance. """ @@ -210,6 +211,7 @@ def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs): renderer = self_or_cls if not isinstance(self_or_cls, Renderer): renderer = self_or_cls.instance() + if not isinstance(obj, Plot): obj = Layout.from_values(obj) if isinstance(obj, AdjointLayout) else obj plot_opts = dict(self_or_cls.plot_options(obj, self_or_cls.size), @@ -226,7 +228,10 @@ def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs): if isinstance(self_or_cls, Renderer): self_or_cls.last_plot = plot - if plot.comm or self_or_cls.mode == 'server': + if comm: + plot.comm = comm + + if comm or self_or_cls.mode == 'server': from bokeh.document import Document from bokeh.io import curdoc if doc is None: @@ -235,6 +240,15 @@ def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs): return plot + @bothmethod + def get_plot_state(self_or_cls, obj, renderer=None, **kwargs): + """ + Given a HoloViews Viewable return a corresponding plot state. + """ + plot = self_or_cls.get_plot(obj, renderer, **kwargs) + return plot.state + + def _validate(self, obj, fmt, **kwargs): """ Helper method to be used in the __call__ method to get a @@ -573,8 +587,15 @@ def load_nb(cls, inline=True): """ load_notebook(inline) with param.logging_level('ERROR'): + try: + ip = get_ipython() # noqa + except: + ip = None + if not ip or not hasattr(ip, 'kernel'): + return cls.notebook_context = True cls.comm_manager = JupyterCommManager + state._comm_manager = JupyterCommManager @classmethod diff --git a/holoviews/tests/plotting/plotly/testcallbacks.py b/holoviews/tests/plotting/plotly/testcallbacks.py new file mode 100644 index 0000000000..c3956f6636 --- /dev/null +++ b/holoviews/tests/plotting/plotly/testcallbacks.py @@ -0,0 +1,340 @@ +from unittest import TestCase + +try: + from unittest.mock import Mock +except: + from mock import Mock + +import uuid +import plotly.graph_objs as go + +from holoviews.plotting.plotly.callbacks import ( + RangeXYCallback, RangeXCallback, RangeYCallback, + BoundsXYCallback, BoundsXCallback, BoundsYCallback, + Selection1DCallback +) + + +def mock_plot(trace_uid=None): + # Build a mock to stand in for a PlotlyPlot subclass + if trace_uid is None: + trace_uid = str(uuid.uuid4()) + + plot = Mock() + plot.trace_uid = trace_uid + return plot + + +def build_callback_set(callback_cls, trace_uids, num_streams=2): + """ + Build a collection of plots, callbacks, and streams for a given callback class and + a list of trace_uids + """ + plots = [] + streamss = [] + callbacks = [] + for trace_uid in trace_uids: + plot = mock_plot(trace_uid) + streams = [Mock() for _ in range(num_streams)] + callback = callback_cls(plot, streams, None) + + plots.append(plot) + streamss.append(streams) + callbacks.append(callback) + + return plots, streamss, callbacks + + +class TestCallbacks(TestCase): + + def setUp(self): + self.fig_dict = go.Figure({ + 'data': [ + {'type': 'scatter', + 'y': [1, 2, 3], + 'uid': 'first'}, + {'type': 'bar', + 'y': [1, 2, 3], + 'uid': 'second', + 'xaxis': 'x', + 'yaxis': 'y'}, + {'type': 'scatter', + 'y': [1, 2, 3], + 'uid': 'third', + 'xaxis': 'x2', + 'yaxis': 'y2'}, + {'type': 'bar', + 'y': [1, 2, 3], + 'uid': 'forth', + 'xaxis': 'x3', + 'yaxis': 'y3'}, + ], + 'layout': { + 'title': {'text': 'Figure Title'}} + }).to_dict() + + def testCallbackClassInstanceTracking(self): + # Each callback class should track all active instances of its own class in a + # weak value dictionary. Here we make sure that instances stay separated per + # class + plot1 = mock_plot() + plot2 = mock_plot() + plot3 = mock_plot() + + # Check RangeXYCallback + rangexy_cb = RangeXYCallback(plot1, [], None) + self.assertIn(plot1.trace_uid, RangeXYCallback.instances) + self.assertIs(rangexy_cb, RangeXYCallback.instances[plot1.trace_uid]) + + # Check BoundsXYCallback + boundsxy_cb = BoundsXYCallback(plot2, [], None) + self.assertIn(plot2.trace_uid, BoundsXYCallback.instances) + self.assertIs(boundsxy_cb, BoundsXYCallback.instances[plot2.trace_uid]) + + # Check Selection1DCallback + selection1d_cb = Selection1DCallback(plot3, [], None) + self.assertIn(plot3.trace_uid, Selection1DCallback.instances) + self.assertIs(selection1d_cb, Selection1DCallback.instances[plot3.trace_uid]) + + # Check that objects don't show up as instances in the wrong class + self.assertNotIn(plot1.trace_uid, BoundsXYCallback.instances) + self.assertNotIn(plot1.trace_uid, Selection1DCallback.instances) + self.assertNotIn(plot2.trace_uid, RangeXYCallback.instances) + self.assertNotIn(plot2.trace_uid, Selection1DCallback.instances) + self.assertNotIn(plot3.trace_uid, RangeXYCallback.instances) + self.assertNotIn(plot3.trace_uid, BoundsXYCallback.instances) + + def testRangeCallbacks(self): + + # Build callbacks + range_classes = [RangeXYCallback, RangeXCallback, RangeYCallback] + + xyplots, xystreamss, xycallbacks = build_callback_set( + RangeXYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + xplots, xstreamss, xcallbacks = build_callback_set( + RangeXCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + yplots, ystreamss, ycallbacks = build_callback_set( + RangeYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + # Sanity check the length of the streams lists + for xystreams in xystreamss: + self.assertEqual(len(xystreams), 2) + + # Change viewport on first set of axes + viewport1 = {'xaxis.range': [1, 4], 'yaxis.range': [-1, 5]} + for cb_cls in range_classes: + cb_cls.update_streams_from_property_update(viewport1, self.fig_dict) + + # Check that all streams attached to 'first' and 'second' plots were triggered + for xystream, xstream, ystream in zip( + xystreamss[0] + xystreamss[1], + xstreamss[0] + xstreamss[1], + ystreamss[0] + ystreamss[1], + ): + xystream.event.assert_called_once_with(x_range=(1, 4), y_range=(-1, 5)) + xstream.event.assert_called_once_with(x_range=(1, 4)) + ystream.event.assert_called_once_with(y_range=(-1, 5)) + + # And that no other streams were triggered + for xystream, xstream, ystream in zip( + xystreamss[2] + xystreamss[3], + xstreamss[2] + xstreamss[3], + ystreamss[2] + ystreamss[3], + ): + xystream.event.assert_called_with(x_range=None, y_range=None) + xstream.event.assert_called_with(x_range=None) + ystream.event.assert_called_with(y_range=None) + + # Change viewport on second set of axes + viewport2 = {'xaxis2.range': [2, 5], 'yaxis2.range': [0, 6]} + for cb_cls in range_classes: + cb_cls.update_streams_from_property_update(viewport2, self.fig_dict) + + # Check that all streams attached to 'third' were triggered + for xystream, xstream, ystream in zip( + xystreamss[2], xstreamss[2], ystreamss[2] + ): + xystream.event.assert_called_with(x_range=(2, 5), y_range=(0, 6)) + xstream.event.assert_called_with(x_range=(2, 5)) + ystream.event.assert_called_with(y_range=(0, 6)) + + # Change viewport on third set of axes + viewport3 = {'xaxis3.range': [3, 6], 'yaxis3.range': [1, 7]} + for cb_cls in range_classes: + cb_cls.update_streams_from_property_update(viewport3, self.fig_dict) + + # Check that all streams attached to 'forth' were triggered + for xystream, xstream, ystream in zip( + xystreamss[3], xstreamss[3], ystreamss[3] + ): + xystream.event.assert_called_with(x_range=(3, 6), y_range=(1, 7)) + xstream.event.assert_called_with(x_range=(3, 6)) + ystream.event.assert_called_with(y_range=(1, 7)) + + # Check that streams attached to a trace not in this plot are not triggered + for xystream, xstream, ystream in zip( + xystreamss[4], xstreamss[4], ystreamss[4], + ): + xystream.event.assert_not_called() + xstream.event.assert_not_called() + ystream.event.assert_not_called() + + def testBoundsCallbacks(self): + + # Build callbacks + bounds_classes = [BoundsXYCallback, BoundsXCallback, BoundsYCallback] + + xyplots, xystreamss, xycallbacks = build_callback_set( + BoundsXYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + xplots, xstreamss, xcallbacks = build_callback_set( + BoundsXCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + yplots, ystreamss, ycallbacks = build_callback_set( + BoundsYCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + # box selection on first set of axes + selected_data1 = {'range': {'x': [1, 4], 'y': [-1, 5]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update(selected_data1, self.fig_dict) + + # Check that all streams attached to 'first' and 'second' plots were triggered + for xystream, xstream, ystream in zip( + xystreamss[0] + xystreamss[1], + xstreamss[0] + xstreamss[1], + ystreamss[0] + ystreamss[1], + ): + xystream.event.assert_called_once_with(bounds=(1, -1, 4, 5)) + xstream.event.assert_called_once_with(boundsx=(1, 4)) + ystream.event.assert_called_once_with(boundsy=(-1, 5)) + + # Check that streams attached to plots in other subplots are called with None + # to clear their bounds + for xystream, xstream, ystream in zip( + xystreamss[2] + xystreamss[3], + xstreamss[2] + xstreamss[3], + ystreamss[2] + ystreamss[3], + ): + xystream.event.assert_called_once_with(bounds=None) + xstream.event.assert_called_once_with(boundsx=None) + ystream.event.assert_called_once_with(boundsy=None) + + # box select on second set of axes + selected_data2 = {'range': {'x2': [2, 5], 'y2': [0, 6]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update(selected_data2, self.fig_dict) + + # Check that all streams attached to 'second' were triggered + for xystream, xstream, ystream in zip( + xystreamss[2], xstreamss[2], ystreamss[2], + ): + xystream.event.assert_called_with(bounds=(2, 0, 5, 6)) + xstream.event.assert_called_with(boundsx=(2, 5)) + ystream.event.assert_called_with(boundsy=(0, 6)) + + # box select on third set of axes + selected_data3 = {'range': {'x3': [3, 6], 'y3': [1, 7]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update(selected_data3, self.fig_dict) + + # Check that all streams attached to 'third' were triggered + for xystream, xstream, ystream in zip( + xystreamss[3], xstreamss[3], ystreamss[3], + ): + xystream.event.assert_called_with(bounds=(3, 1, 6, 7)) + xstream.event.assert_called_with(boundsx=(3, 6)) + ystream.event.assert_called_with(boundsy=(1, 7)) + + # lasso select on first set of axes should clear all bounds + selected_data_lasso = {'lassoPoints': {'x': [1, 4, 2], 'y': [-1, 5, 2]}} + for cb_cls in bounds_classes: + cb_cls.update_streams_from_property_update( + selected_data_lasso, self.fig_dict) + + # Check that all streams attached to this figure are called with None + # to clear their bounds + for xystream, xstream, ystream in zip( + xystreamss[0] + xystreamss[1] + xystreamss[2] + xystreamss[3], + xstreamss[0] + xstreamss[1] + xstreamss[2] + xstreamss[3], + ystreamss[0] + ystreamss[1] + ystreamss[2] + ystreamss[3], + ): + xystream.event.assert_called_with(bounds=None) + xstream.event.assert_called_with(boundsx=None) + ystream.event.assert_called_with(boundsy=None) + + # Check that streams attached to plots not in this figure are not called + for xystream, xstream, ystream in zip( + xystreamss[4], xstreamss[4], ystreamss[4] + ): + xystream.event.assert_not_called() + xstream.event.assert_not_called() + ystream.event.assert_not_called() + + def testSelection1DCallback(self): + plots, streamss, callbacks = build_callback_set( + Selection1DCallback, ['first', 'second', 'third', 'forth', 'other'], 2 + ) + + # Select points from the 'first' plot (first set of axes) + selected_data1 = {'points': [ + {"pointNumber": 0, "curveNumber": 0}, + {"pointNumber": 2, "curveNumber": 0}, + ]} + Selection1DCallback.update_streams_from_property_update( + selected_data1, self.fig_dict) + + # Check that all streams attached to the 'first' plots were triggered + for stream in streamss[0]: + stream.event.assert_called_once_with(index=[0, 2]) + + # Check that all streams attached to other plots in this figure were triggered + # with empty selection + for stream in streamss[1] + streamss[2] + streamss[3]: + stream.event.assert_called_once_with(index=[]) + + # Select points from the 'first' and 'second' plot (first set of axes) + selected_data1 = {'points': [ + {"pointNumber": 0, "curveNumber": 0}, + {"pointNumber": 1, "curveNumber": 0}, + {"pointNumber": 1, "curveNumber": 1}, + {"pointNumber": 2, "curveNumber": 1}, + ]} + Selection1DCallback.update_streams_from_property_update( + selected_data1, self.fig_dict) + + # Check that all streams attached to the 'first' plot were triggered + for stream in streamss[0]: + stream.event.assert_called_with(index=[0, 1]) + + # Check that all streams attached to the 'second' plot were triggered + for stream in streamss[1]: + stream.event.assert_called_with(index=[1, 2]) + + # Check that all streams attached to other plots in this figure were triggered + # with empty selection + for stream in streamss[2] + streamss[3]: + stream.event.assert_called_with(index=[]) + + # Select points from the 'forth' plot (third set of axes) + selected_data1 = {'points': [ + {"pointNumber": 0, "curveNumber": 3}, + {"pointNumber": 2, "curveNumber": 3}, + ]} + Selection1DCallback.update_streams_from_property_update( + selected_data1, self.fig_dict) + + # Check that all streams attached to the 'forth' plot were triggered + for stream in streamss[3]: + stream.event.assert_called_with(index=[0, 2]) + + # Check that streams attached to plots not in this figure are not called + for stream in streamss[4]: + stream.event.assert_not_called() diff --git a/holoviews/tests/plotting/plotly/testdynamic.py b/holoviews/tests/plotting/plotly/testdynamic.py new file mode 100644 index 0000000000..e240949abd --- /dev/null +++ b/holoviews/tests/plotting/plotly/testdynamic.py @@ -0,0 +1,170 @@ +from unittest import TestCase + +try: + from unittest.mock import Mock +except: + from mock import Mock + +import holoviews as hv +import panel as pn +import numpy as np + +from holoviews.streams import ( + Stream, Selection1D, RangeXY, BoundsXY, +) + +import holoviews.plotting.plotly # noqa (Activate backend) +hv.Store.set_current_backend("plotly") + +from bokeh.document import Document +from pyviz_comms import Comm + + +class TestDynamicMap(TestCase): + + def test_update_dynamic_map_with_stream(self): + ys = np.arange(10) + + # Build stream + Scale = Stream.define('Scale', scale=1.0) + scale_stream = Scale() + + # Build DynamicMap + def build_scatter(scale): + return hv.Scatter(ys * scale) + + dmap = hv.DynamicMap(build_scatter, streams=[scale_stream]) + + # Create HoloViews Pane using panel so that we can access the plotly pane + # used to display the plotly figure + dmap_pane = pn.pane.HoloViews(dmap, backend='plotly') + + # Call get_root to force instantiation of internal plots/models + doc = Document() + comm = Comm() + dmap_pane.get_root(doc, comm) + + # Get reference to the plotly pane + _, plotly_pane = next(iter(dmap_pane._plots.values())) + + # Check initial data + data = plotly_pane.object['data'] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['type'], 'scatter') + np.testing.assert_equal(data[0]['y'], ys) + + # Watch object for changes + fn = Mock() + plotly_pane.param.watch(fn, 'object') + + # Update stream + scale_stream.event(scale=2.0) + + # Check that figure object was updated + data = plotly_pane.object['data'] + np.testing.assert_equal(data[0]['y'], ys * 2.0) + + # Check that object callback was triggered + fn.assert_called_once() + args, kwargs = fn.call_args_list[0] + event = args[0] + self.assertIs(event.obj, plotly_pane) + self.assertIs(event.new, plotly_pane.object) + + +class TestInteractiveStream(TestCase): + # Note: Testing the core logic of each interactive stream should take place in + # testcallbacks.py. Here we are testing that that callbacks are properly + # routed to streams + + def test_interactive_streams(self): + ys = np.arange(10) + scatter1 = hv.Scatter(ys) + scatter2 = hv.Scatter(ys) + scatter3 = hv.Scatter(ys) + + # Single stream on the first scatter + rangexy1 = RangeXY(source=scatter1) + + # Multiple streams of the same type on second scatter + boundsxy2a = BoundsXY(source=scatter2) + boundsxy2b = BoundsXY(source=scatter2) + + # Multiple streams of different types on third scatter + rangexy3 = RangeXY(source=scatter3) + boundsxy3 = BoundsXY(source=scatter3) + selection1d3 = Selection1D(source=scatter3) + + # Build layout and layout Pane + layout = scatter1 + scatter2 + scatter3 + layout_pane = pn.pane.HoloViews(layout, backend='plotly') + + # Get plotly pane reference + doc = Document() + comm = Comm() + layout_pane.get_root(doc, comm) + _, plotly_pane = next(iter(layout_pane._plots.values())) + + # Simulate zoom and check that RangeXY streams updated accordingly + plotly_pane.viewport = { + 'xaxis.range': [1, 3], + 'yaxis.range': [2, 4], + 'xaxis2.range': [3, 5], + 'yaxis2.range': [4, 6], + 'xaxis3.range': [5, 7], + 'yaxis3.range': [6, 8], + } + + self.assertEqual(rangexy1.x_range, (1, 3)) + self.assertEqual(rangexy1.y_range, (2, 4)) + self.assertEqual(rangexy3.x_range, (5, 7)) + self.assertEqual(rangexy3.y_range, (6, 8)) + + plotly_pane.viewport = None + self.assertIsNone(rangexy1.x_range) + self.assertIsNone(rangexy1.y_range) + self.assertIsNone(rangexy3.x_range) + self.assertIsNone(rangexy3.y_range) + + # Simulate box selection and check that BoundsXY and Selection1D streams + # update accordingly + + # Box select on second subplot + plotly_pane.selected_data = { + 'points': [], + 'range': { + 'x2': [10, 20], + 'y2': [11, 22] + } + } + + self.assertEqual(boundsxy2a.bounds, (10, 11, 20, 22)) + self.assertEqual(boundsxy2b.bounds, (10, 11, 20, 22)) + + # Box selecrt on third subplot + plotly_pane.selected_data = { + 'points': [ + {'curveNumber': 2, 'pointNumber': 0}, + {'curveNumber': 2, 'pointNumber': 3}, + {'curveNumber': 2, 'pointNumber': 7}, + ], + 'range': { + 'x3': [0, 5], + 'y3': [1, 6] + } + } + + self.assertEqual(boundsxy3.bounds, (0, 1, 5, 6)) + self.assertEqual(selection1d3.index, [0, 3, 7]) + + # bounds streams on scatter 2 are None + self.assertIsNone(boundsxy2a.bounds) + self.assertIsNone(boundsxy2b.bounds) + + # Clear selection + plotly_pane.selected_data = None + self.assertIsNone(boundsxy3.bounds) + self.assertIsNone(boundsxy2a.bounds) + self.assertIsNone(boundsxy2b.bounds) + self.assertEqual(selection1d3.index, []) + diff --git a/holoviews/tests/plotting/plotly/testelementplot.py b/holoviews/tests/plotting/plotly/testelementplot.py index 2148b1e1ec..5adf50afdf 100644 --- a/holoviews/tests/plotting/plotly/testelementplot.py +++ b/holoviews/tests/plotting/plotly/testelementplot.py @@ -18,7 +18,7 @@ def history_callback(x, history=deque(maxlen=10)): stream = PointerX(x=0) dmap = DynamicMap(history_callback, kdims=[], streams=[stream]) plot = plotly_renderer.get_plot(dmap) - plotly_renderer(plot) + plotly_renderer(dmap) for i in range(20): stream.event(x=i) state = plot.state diff --git a/holoviews/util/__init__.py b/holoviews/util/__init__.py index 3207461084..98e9a53c4b 100644 --- a/holoviews/util/__init__.py +++ b/holoviews/util/__init__.py @@ -797,7 +797,7 @@ def render(obj, backend=None, **kwargs): plot = renderer_obj.get_plot(obj) if backend == 'matplotlib' and len(plot) > 1: return plot.anim(fps=renderer_obj.fps) - return renderer_obj.get_plot(obj).state + return renderer_obj.get_plot_state(obj) class Dynamic(param.ParameterizedFunction): diff --git a/setup.py b/setup.py index 29bbb0a54d..12bdad4ffc 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ # IPython Notebook + pandas + matplotlib + bokeh extras_require['recommended'] = extras_require['notebook'] + [ - 'pandas', 'matplotlib>=2.1', 'bokeh>=1.1.0,<2.0.0', 'panel'] + 'pandas', 'matplotlib>=2.1', 'bokeh>=1.1.0,<2.0.0', 'panel>=0.7.0a8'] # Requirements to run all examples extras_require['examples'] = extras_require['recommended'] + [ @@ -32,7 +32,7 @@ 'cyordereddict', 'pscript==0.7.1'] # Test requirements -extras_require['tests'] = ['nose', 'flake8==3.6.0', 'coveralls', 'path.py', 'matplotlib>=2.1,<3.1'] +extras_require['tests'] = ['nose', 'mock', 'flake8==3.6.0', 'coveralls', 'path.py', 'matplotlib>=2.1,<3.1'] extras_require['unit_tests'] = extras_require['examples']+extras_require['tests']