Skip to content
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

Dynamic Callable API #951

Merged
merged 10 commits into from
Oct 31, 2016
12 changes: 11 additions & 1 deletion holoviews/core/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from .element import Element, HoloMap, GridSpace, Collator
from .layout import Layout
from .overlay import NdOverlay, Overlay
from .spaces import DynamicMap
from .spaces import DynamicMap, Callable
from .traversal import unique_dimkeys
from . import util

Expand Down Expand Up @@ -160,6 +160,16 @@ def __call__(self, element, **params):
return processed


class OperationCallable(Callable):
"""
OperationCallable allows wrapping an ElementOperation and the
objects it is processing to allow traversing the operations
applied on a DynamicMap.
"""

operation = param.ClassSelector(class_=ElementOperation, doc="""
The ElementOperation being wrapped.""")


class MapOperation(param.ParameterizedFunction):
"""
Expand Down
10 changes: 7 additions & 3 deletions holoviews/core/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ class Overlayable(object):

def __mul__(self, other):
if type(other).__name__ == 'DynamicMap':
from ..util import Dynamic
def dynamic_mul(element):
from .spaces import Callable
def dynamic_mul(*args, **kwargs):
element = other[args]
return self * element
return Dynamic(other, operation=dynamic_mul)
callback = Callable(callable_function=dynamic_mul,
inputs=[self, other])
return other.clone(shared_data=False, callback=callback,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering whether it should be self.clone, or other.clone or maybe a new DynamicMap declaration entirely. I see this is in the condition where other is a DynamicMapbut is this definitely right in terms of kdims? I need to think about it more...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this stuff relying on other being DynamicMap be moved to _dynamic_mul? There is already this condition in __mul__:

if isinstance(self, DynamicMap) or isinstance(other, DynamicMap):
    return self._dynamic_mul(dimensions, other, super_keys)

If all the logic regarding dynamic could move to _dynamic_mul, that would be cleaner...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering whether it should be self.clone, or other.clone or maybe a new DynamicMap declaration entirely. I see this is in the condition where other is a DynamicMapbut is this definitely right in terms of kdims?

Yes, this is the condition where self is a single Element or Overlay.

If all the logic regarding dynamic could move to _dynamic_mul, that would be cleaner...

This is the __mul__ implementation on Overlayable, it doesn't have _dynamic_mul, because I'd like to avoid inline imports.

streams=[])
if isinstance(other, UniformNdMapping) and not isinstance(other, CompositeOverlay):
items = [(k, self * v) for (k, v) in other.items()]
return other.clone(items)
Expand Down
59 changes: 49 additions & 10 deletions holoviews/core/spaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ def _dynamic_mul(self, dimensions, other, keys):
map_obj = self if isinstance(self, DynamicMap) else other
mode = map_obj.mode

def dynamic_mul(*key):
def dynamic_mul(*key, **kwargs):
key = key[0] if mode == 'open' else key
layers = []
try:
if isinstance(self, DynamicMap):
_, self_el = util.get_dynamic_item(self, dimensions, key)
safe_key = () if not self.kdims else key
_, self_el = util.get_dynamic_item(self, dimensions, safe_key)
if self_el is not None:
layers.append(self_el)
else:
Expand All @@ -134,19 +135,21 @@ def dynamic_mul(*key):
pass
try:
if isinstance(other, DynamicMap):
_, other_el = util.get_dynamic_item(other, dimensions, key)
safe_key = () if not other.kdims else key
_, other_el = util.get_dynamic_item(other, dimensions, safe_key)
if other_el is not None:
layers.append(other_el)
else:
layers.append(other[key])
except KeyError:
pass
return Overlay(layers)
callback = Callable(callable_function=dynamic_mul, inputs=[self, other])
if map_obj:
return map_obj.clone(callback=dynamic_mul, shared_data=False,
kdims=dimensions)
return map_obj.clone(callback=callback, shared_data=False,
kdims=dimensions, streams=[])
else:
return DynamicMap(callback=dynamic_mul, kdims=dimensions)
return DynamicMap(callback=callback, kdims=dimensions)


def __mul__(self, other):
Expand Down Expand Up @@ -204,10 +207,13 @@ def __mul__(self, other):
return self.clone(items, kdims=dimensions, label=self._label, group=self._group)
elif isinstance(other, self.data_type):
if isinstance(self, DynamicMap):
from ..util import Dynamic
def dynamic_mul(element):
def dynamic_mul(*args, **kwargs):
element = self[args]
return element * other
return Dynamic(self, operation=dynamic_mul)
callback = Callable(callable_function=dynamic_mul,
inputs=[self, other])
return self.clone(shared_data=False, callback=callback,
streams=[])
items = [(k, v * other) for (k, v) in self.data.items()]
return self.clone(items, label=self._label, group=self._group)
else:
Expand Down Expand Up @@ -393,6 +399,38 @@ def hist(self, num_bins=20, bin_range=None, adjoin=True, individually=True, **kw
return histmaps[0]


class Callable(param.Parameterized):
"""
Callable allows wrapping callbacks on one or more DynamicMaps
allowing their inputs (and in future outputs) to be defined.
This makes it possible to wrap DynamicMaps with streams and
makes it possible to traverse the graph of operations applied
to a DynamicMap.
"""

callable_function = param.Callable(default=lambda x: x, doc="""
The callable function being wrapped.""")

inputs = param.List(default=[], doc="""
The list of inputs the callable function is wrapping.""")

def __call__(self, *args, **kwargs):
return self.callable_function(*args, **kwargs)


def get_nested_streams(dmap):
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would get_nested_streams or nested_streams be a better name?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure.

Get all (potentially nested) streams from DynamicMap with Callable
callback.
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might say something like 'Get all (potentially nested) streams ...'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure.

layer_streams = list(dmap.streams)
if not isinstance(dmap.callback, Callable):
return layer_streams
for o in dmap.callback.inputs:
if isinstance(o, DynamicMap):
layer_streams += get_nested_streams(o)
return layer_streams


class DynamicMap(HoloMap):
"""
Expand Down Expand Up @@ -689,7 +727,8 @@ def __getitem__(self, key):

# Cache lookup
try:
dimensionless = util.dimensionless_contents(self.streams, self.kdims)
dimensionless = util.dimensionless_contents(get_nested_streams(self),
self.kdims, no_duplicates=False)
if (dimensionless and not self._dimensionless_cache):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using no_duplicates=False would be clearer here...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure.

raise KeyError('Using dimensionless streams disables DynamicMap cache')
cache = super(DynamicMap,self).__getitem__(key)
Expand Down
8 changes: 4 additions & 4 deletions holoviews/core/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,21 +799,21 @@ def stream_parameters(streams, no_duplicates=True, exclude=['name']):
return [name for name in names if name not in exclude]


def dimensionless_contents(streams, kdims):
def dimensionless_contents(streams, kdims, no_duplicates=True):
"""
Return a list of stream parameters that have not been associated
with any of the key dimensions.
"""
names = stream_parameters(streams)
names = stream_parameters(streams, no_duplicates)
return [name for name in names if name not in kdims]


def unbound_dimensions(streams, kdims):
def unbound_dimensions(streams, kdims, no_duplicates=True):
"""
Return a list of dimensions that have not been associated with
any streams.
"""
params = stream_parameters(streams)
params = stream_parameters(streams, no_duplicates)
return [d for d in kdims if d not in params]


Expand Down
29 changes: 20 additions & 9 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from ...element import RGB
from ...streams import Stream, RangeXY, RangeX, RangeY
from ..plot import GenericElementPlot, GenericOverlayPlot
from ..util import dynamic_update
from ..util import dynamic_update, get_sources
from .plot import BokehPlot
from .util import (mpl_to_bokeh, convert_datetime, update_plot,
bokeh_version, mplcmap_to_palette)
Expand Down Expand Up @@ -177,15 +177,18 @@ def _construct_callbacks(self):
the plotted object as a source.
"""
if not self.static or isinstance(self.hmap, DynamicMap):
source = self.hmap
sources = [(i, o) for i, o in get_sources(self.hmap)
if i in [None, self.zorder]]
else:
source = self.hmap.last
streams = Stream.registry.get(id(source), [])
registry = Stream._callbacks['bokeh']
callbacks = {(registry[type(stream)], stream) for stream in streams
if type(stream) in registry and streams}
sources = [(self.zorder, self.hmap.last)]
cb_classes = set()
for _, source in sources:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the key difference seems to be that you can now have multiple sources whereas before there was only one. And the sources are now retrieved recursively via get_sources which works via the Callable instances...

streams = Stream.registry.get(id(source), [])
registry = Stream._callbacks['bokeh']
cb_classes |= {(registry[type(stream)], stream) for stream in streams
if type(stream) in registry and streams}
cbs = []
sorted_cbs = sorted(callbacks, key=lambda x: id(x[0]))
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))
Expand Down Expand Up @@ -560,6 +563,11 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):
if plot is None:
plot = self._init_plot(key, style_element, ranges=ranges, plots=plots)
self._init_axes(plot)
else:
self.handles['xaxis'] = plot.xaxis[0]
self.handles['x_range'] = plot.x_range
self.handles['y_axis'] = plot.yaxis[0]
self.handles['y_range'] = plot.y_range
self.handles['plot'] = plot

# Get data and initialize data source
Expand Down Expand Up @@ -675,7 +683,10 @@ def current_handles(self):
rangex, rangey = True, True
elif isinstance(self.hmap, DynamicMap):
rangex, rangey = True, True
for stream in self.hmap.streams:
subplots = list(self.subplots.values()) if self.subplots else []
callbacks = [cb for p in [self]+subplots for cb in p.callbacks]
streams = [s for cb in callbacks for s in cb.streams]
for stream in streams:
if isinstance(stream, RangeXY):
rangex, rangey = False, False
break
Expand Down
13 changes: 8 additions & 5 deletions holoviews/plotting/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from ..core.util import stream_parameters
from ..element import Table
from .util import (get_dynamic_mode, initialize_sampled, dim_axis_label,
attach_streams, traverse_setter)
attach_streams, traverse_setter, get_nested_streams)


class Plot(param.Parameterized):
Expand Down Expand Up @@ -578,7 +578,10 @@ def __init__(self, element, keys=None, ranges=None, dimensions=None,
**dict(params, **plot_opts))
if top_level:
self.comm = self.init_comm(element)
self.streams = self.hmap.streams if isinstance(self.hmap, DynamicMap) else []
streams = []
if isinstance(self.hmap, DynamicMap):
streams = get_nested_streams(self.hmap)
self.streams = streams

# Update plot and style options for batched plots
if self.batched:
Expand Down Expand Up @@ -928,9 +931,9 @@ def __init__(self, layout, keys=None, dimensions=None, **params):
if top_level:
self.comm = self.init_comm(layout)
self.traverse(lambda x: setattr(x, 'comm', self.comm))
self.streams = [s for streams in layout.traverse(lambda x: x.streams,
[DynamicMap])
for s in streams]
nested_streams = layout.traverse(lambda x: get_nested_streams(x),
[DynamicMap])
self.streams = [s for streams in nested_streams for s in streams]


def _get_frame(self, key):
Expand Down
2 changes: 1 addition & 1 deletion holoviews/plotting/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def _validate(self, obj, fmt):
if (((len(plot) == 1 and not plot.dynamic)
or (len(plot) > 1 and self.holomap is None) or
(plot.dynamic and len(plot.keys[0]) == 0)) or
not unbound_dimensions(plot.streams, plot.dimensions)):
not unbound_dimensions(plot.streams, plot.dimensions, no_duplicates=False)):
fmt = fig_formats[0] if self.fig=='auto' else self.fig
else:
fmt = holomap_formats[0] if self.holomap=='auto' else self.holomap
Expand Down
30 changes: 28 additions & 2 deletions holoviews/plotting/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import param

from ..core import (HoloMap, DynamicMap, CompositeOverlay, Layout,
GridSpace, NdLayout, Store, Overlay)
GridSpace, NdLayout, Store, Callable, Overlay)
from ..core.spaces import get_nested_streams
from ..core.util import (match_spec, is_number, wrap_tuple, basestring,
get_overlay_spec, unique_iterator, safe_unicode)

Expand Down Expand Up @@ -295,11 +296,36 @@ def attach_streams(plot, obj):
Attaches plot refresh to all streams on the object.
"""
def append_refresh(dmap):
for stream in dmap.streams:
for stream in get_nested_streams(dmap):
stream._hidden_subscribers.append(plot.refresh)
return obj.traverse(append_refresh, [DynamicMap])


def get_sources(obj, index=None):
"""
Traverses Callable graph to resolve sources on
DynamicMap objects, returning a list of sources
indexed by the Overlay layer.
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this be simplified to:

if isinstance(obj, DynamicMap) and isinstance(obj.callback, Callable):
    return [(index, obj)]

leaving the rest of the function to handle DynamicMaps with Callable callbacks?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite but it could definitely be refactored more nicely.

Copy link
Contributor

@jlstevens jlstevens Oct 31, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah...given it is:

 +   if isinstance(obj, DynamicMap):
 +        if isinstance(obj.callback, Callable):
                  ....
 +        else:
 +            return [(index, obj)]
 +    else:
 +        return [(index, obj)]

I think I meant:

if not isinstance(obj, DynamicMap) or not isinstance(obj.callback, Callable):
    return [(index, obj)]

layers = [(index, obj)]
if not isinstance(obj, DynamicMap) or not isinstance(obj.callback, Callable):
return layers
index = 0 if index is None else int(index)
for o in obj.callback.inputs:
if isinstance(o, Overlay):
layers.append((None, o))
for i, o in enumerate(overlay):
layers.append((index+i, o))
index += len(o)
elif isinstance(o, DynamicMap):
layers += get_sources(o, index)
index = layers[-1][0]+1
else:
layers.append((index, o))
index += 1
return layers


def traverse_setter(obj, attribute, value):
"""
Traverses the object and sets the supplied attribute on the
Expand Down
6 changes: 5 additions & 1 deletion holoviews/plotting/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ def __init__(self, plot, renderer=None, **params):
super(NdWidget, self).__init__(**params)
self.id = plot.comm.target if plot.comm else uuid.uuid4().hex
self.plot = plot
self.dimensions, self.keys = drop_streams(plot.streams,
streams = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this bit is simply a bug fix and otherwise unrelated?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this wasn't an issue, so not unrelated.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok...seems to be a result of plot.streams potentially having many more instances due to the nesting than before...

Copy link
Member Author

@philippjfr philippjfr Oct 31, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right in these cases it just needs to know if a kdim has a corresponding stream or not, there is no actual clash at the DynamicMap level because each level of wrapping, i.e. all operations you apply, resolve their own streams.

for stream in plot.streams:
if any(k in plot.dimensions for k in stream.contents):
streams.append(stream)
self.dimensions, self.keys = drop_streams(streams,
plot.dimensions,
plot.keys)

Expand Down
27 changes: 16 additions & 11 deletions holoviews/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from .core import DynamicMap, ViewableElement
from .core.operation import ElementOperation
from .core.util import Aliases
from .core.operation import OperationCallable
from .core.spaces import Callable
from .core import util
from .streams import Stream

Expand Down Expand Up @@ -33,7 +35,8 @@ def __call__(self, map_obj, **params):
self.p = param.ParamOverrides(self, params)
callback = self._dynamic_operation(map_obj)
if isinstance(map_obj, DynamicMap):
dmap = map_obj.clone(callback=callback, shared_data=False)
dmap = map_obj.clone(callback=callback, shared_data=False,
streams=[])
else:
dmap = self._make_dynamic(map_obj, callback)
if isinstance(self.p.operation, ElementOperation):
Expand All @@ -44,7 +47,7 @@ def __call__(self, map_obj, **params):
elif not isinstance(stream, Stream):
raise ValueError('Stream must only contain Stream '
'classes or instances')
stream.update(**{k: self.p.operation.p.get(k) for k, v in
stream.update(**{k: self.p.operation.p.get(k, v) for k, v in
stream.contents.items()})
streams.append(stream)
return dmap.clone(streams=streams)
Expand All @@ -69,15 +72,17 @@ def _dynamic_operation(self, map_obj):
def dynamic_operation(*key, **kwargs):
self.p.kwargs.update(kwargs)
return self._process(map_obj[key], key)
return dynamic_operation

def dynamic_operation(*key, **kwargs):
key = key[0] if map_obj.mode == 'open' else key
self.p.kwargs.update(kwargs)
_, el = util.get_dynamic_item(map_obj, map_obj.kdims, key)
return self._process(el, key)

return dynamic_operation
else:
def dynamic_operation(*key, **kwargs):
key = key[0] if map_obj.mode == 'open' else key
self.p.kwargs.update(kwargs)
_, el = util.get_dynamic_item(map_obj, map_obj.kdims, key)
return self._process(el, key)
if isinstance(self.p.operation, ElementOperation):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't self.p.operation be folded into callable_function so you only need Callable and not OperationCallable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No because Dynamic performs some wrapping around the operation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused. _dynamic_operation seems to be called once here and I don't see the operation parameter then being inspected/used on the callback variable anywhere in Dynamic. I am yet to spot where the operation parameter of OperationCallable is used...so I'm assuming I've missed it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is never used, but is useful information and will in future be used to look up the outputs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, I'm confused different bit of code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if isinstance(self.p.operation, ElementOperation):
            kwargs = {k: v for k, v in self.p.kwargs.items()
                      if k in self.p.operation.params()}
            return self.p.operation.process_element(element, key, **kwargs)

return OperationCallable(callable_function=dynamic_operation,
inputs=[map_obj], operation=self.p.operation)
else:
return Callable(callable_function=dynamic_operation, inputs=[map_obj])


def _make_dynamic(self, hmap, dynamic_fn):
Expand Down