Replace holoviews notebook rendering with a Panel

Hi there,

I was wondering if there is a clean and future-proof way of replacing what jupyterlab displays for holoviews elements with a custom panel ?

I’m working on a library that exposes a number of functions returning (mostly) time series.
To support data analysis, I developed a panel that adds a dataframe widget under the plot, synchronized with a VLine cursor (clicking on the cursor scrolls on the df, and vice versa).

I would like to be able to display the extra widgets under my plot, but stick to a holoviews object instead of a Panel so that the output of various functions can be combined easily (Layout, Overlay, custom .options() etc).

My current workarounds are:

  1. do not display the extra widget by default :frowning_face:
  2. Use IPython.display.display to display the panel in cell’s output. That is a bit buggy:
    • The widgets can only be over the plot, as they are displayed as a side effect, thereby before the plot itself
    • If you have a cell that just assigns figures to variables before displaying them later, the cell output still contains all the widgets, which should not really be shown there.

EDIT: here is the panel in question, displaying a dataframe in tabs

You should still be able to access the holoviews object by doing

If holoviews object is nested inside column:
pn.Column.objects[0].object
or if it’s holoviews object
pn.pane.HoloViews().object

accessing objects[0] is a start but not really maintainable:

  • It’s still extra hassle for the users, who will probably just give up on the API I provide
  • It is highly dependent on the layout, which is not at all something I can make a reasonable promise not to break.

That said I could probably use this API to programmatically get a reference on the holoviews plot, and wrap the whole thing in a proxy object that would relay all method calls to it, apart from the IPython display functions. The main complication in that is that holoviews routinely inspects the type of the operands, so I will have to make a wrapper type per holoviews element that inherits from it, so it’s recognized as expected by isinstance().

Ok so following @ahuang11 suggestion I came up with the following horror.

It displays like the passed panel in jupyterlab, but is actually just a holoviews plot, so you can still combine it with overlays etc. The main potential issue are:

  1. it won’t render as a pane anywhere else than in jupyterlab
  2. If added inside a larger pane, it will behave as a simple holoviews plot, rather than bringing in the extra bits.

Neither of these points are a real issues for me as this is only to give a nicer default UI for jupyterlab users, and any larger pane would bring its own set of controls, so the figure itself does not need to bring its own.

import inspect
import copy

import holoviews as hv
hv.extension('bokeh')

def pane_to_figure(pane):
    """
    Convert a pane containing a holoviews figure into a holoviews figure, that will display as the pane in jupyterlab.
    """
    # TODO: use something that traverses the panel to collect holoviews figures
    fig = pane.objects[0].object
    print(type(fig))
    
    # Shallow copy as we are going to monkey-patch the object
    fig = copy.copy(fig)
    
    for attr, x in inspect.getmembers(pane):
        if attr.startswith('_repr_'):
            # Monkey patch bound methods directly
            setattr(fig, attr, x)
            
    return fig


curve = hv.Curve([0,1])
pane = pn.Row(curve, pn.widgets.Button(name='click me'))
figpane = pane_to_figure(pane)

# When displayed on its own, it is like the original "pane"
figpane

# But it is actually just a holoviews plot, so it can be combined with any other one
figpane * hv.Curve([3, 4])

Actually there is a pretty big issue: it breaks opts() (options are ignored) and .options() simply returns a new object, but apparently not by simply copying itself and updating some attributes, so the monkey-patching is lost in the process.

Sorry I am not following what you’re trying to do.

Are you trying to overload panel objects be able to use + and * operators? e.g.

panel_obj * holoviews_obj?

I’m more trying to overload a holoviews plot so that it displays a panel rather than the bare plot. The API that is most useful for users to manipulate is the one from holoviews, since they will probably want to change colors, overlay the plot etc. But I want the default view of the plot to include panel widgets as seen in the gif I posted.

1 Like

pn.pane.HoloViews Holoviews — Panel 0.11.3 documentation?

Unless I’m mistaken pn.pane.Holoviews is not exposing the API of holoviews so it does not fit what I’m trying to do:

import holoviews as hv
hv.extension('bokeh')

fig = hv.Curve([0,1])

pane=pn.pane.HoloViews(fig)
pane * pane
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/tmp/ipykernel_116434/615195475.py in <module>
      5 
      6 pane=pn.pane.HoloViews(fig)
----> 7 pane * pane

TypeError: unsupported operand type(s) for *: 'HoloViews' and 'HoloViews'
pane.opts(color='red')
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipykernel_116434/772543966.py in <module>
      5 
      6 pane=pn.pane.HoloViews(fig)
----> 7 pane.opts(color='red')

AttributeError: 'HoloViews' object has no attribute 'opts'

I tried to improve the monkey patching PoC but I think it’s a dead-end for multiple reasons, one being that it forces to create a new class per instance (we cannot monkey patch special methods such as __add__ on instances) and register it in holoviews, thereby introducing a memory leak.

I’ll park this thing for now unless there is a solution readily available. Maybe the best way to go about that is to introduce a hook in holoviews that will be called by IPython repr*_ methods to turn the plot into a panel right before display, and make it “contagious” when composing the plot.

Wondering if you can overload the *

def __mul__(a, b):
    a.object = a.object * b.object
    return a

I suppose I could try to subclass the panel classes but:

  1. The main API I’m interested in is the one of holoviews elements.
  2. Subclassing seems to work on panel but I don’t know how future proof that is:
  • Subclassing on holoviews forces to register the class. Panel seems to not require that but is it always gonna be this way ?
  • In holoviews, one cannot override __init__ signature otherwise options() will stop working. I expect similar problems with Panel.
  1. Defining __mul__ in panel’s subclass might work now, but if Panel starts using it in the future, a) the new feature will not be usable and b) my implementation might break internal code that relies on panel’s definition. There is no guarantee that __mul__ (or any other method) is purely a public API and that the internal code relies on another API.

As a stop-gap solution I could probably create my own class that does not inherit from either holoviews or panel but just dispatches method calls to either objects. Holoviews won’t want to compose with that but maybe __radd__/__rmul__ etc can fix that. At least that avoids overriding any API that might be used internally by holoviews and panel instances.

1 Like

FWIW this seems to be working relatively well:

import inspect
import functools

@functools.lru_cache(typed=True, maxsize=None)
def _hv_wrap_fig_cls(cls):
    
    def wrap_fig(self, x):
        if x.__class__.__module__.startswith('holoviews'):
            return self.__class__(
                fig=x,
                make_pane=self._make_pane,
            )
        else:
            return x
    
    def make_wrapper(f):
        @functools.wraps(f)
        def wrapper(self, *args, **kwargs):
            x = f(self._fig, *args, **kwargs)
            return wrap_fig(self, x)

        return wrapper
    
    def make_op(name):
        def op(self, *args, **kwargs):
            f = getattr(self._fig, name)
            x = f(*args, **kwargs)
            return wrap_fig(self, x)
        return op
    
    class NewCls:
        def __init__(self, fig, make_pane):
            self._fig = fig
            self._make_pane = make_pane
            
        def _repr_mimebundle_(self, *args, **kwargs):
            pane = self._make_pane(self._fig)
            return pane._repr_mimebundle_(*args, **kwargs)
        
        def opts(self, *args, **kwargs):
            return wrap_fig(
                self,
                self._fig.opts(*args, **kwargs),
            )
            
    for attr, x in inspect.getmembers(cls):
        if not attr.startswith('_') and inspect.isfunction(x):
            x = make_wrapper(x)
            setattr(NewCls, attr, x)
            
    for name in (
        '__add__',
        '__radd__',
        '__mul__',
        '__rmul__',
    ):
        setattr(NewCls, name, make_op(name))
        
    return NewCls


def _hv_fig_to_pane(fig, make_pane):
    cls = _hv_wrap_fig_cls(fig.__class__)
    return cls(fig=fig, make_pane=make_pane)

# Example
fig = hv.Curve([0,1])
def make_pane(fig):
    return pn.Row(fig, pn.widgets.Button(name='hello'))
x = _hv_fig_to_pane(fig, make_pane)

x.options(color='red')
x.opts(color='yellow')
x.opts(color='blue')

The problem is that NewCls is not a subclass of the holoviews hiearchy, which makes it take different paths in the code, leading to an exception when doing:

 hv.Curve([3,4]) * x

I guess holoviews should handle that case gracefully and return NotImplemented, so that NewCls.__rmul__ can be tried: Built-in Constants — Python 3.9.6 documentation