diff --git a/hvplot/interactive.py b/hvplot/interactive.py index baabfd574..bac928381 100644 --- a/hvplot/interactive.py +++ b/hvplot/interactive.py @@ -1,160 +1,23 @@ """ interactive API -How Interactive works ---------------------- - -`Interactive` is a wrapper around a Python object that lets users create -interactive pipelines by calling existing APIs on an object with dynamic -parameters or widgets. - -An `Interactive` instance watches what operations are applied to the object. - -To do so, each operation returns a new `Interactive` instance - the creation -of a new instance being taken care of by the `_clone` method - which allows -the next operation to be recorded, and so on and so forth. E.g. `dfi.head()` -first records that the `'head'` attribute is accessed, this is achieved -by overriding `__getattribute__`. A new interactive object is returned, -which will then record that it is being called, and that new object will be -itself called as `Interactive` implements `__call__`. `__call__` returns -another `Interactive` instance. - -Note that under the hood even more `Interactive` instances may be created, -but this is the gist of it. - -To be able to watch all the potential operations that may be applied to an -object, `Interactive` implements on top of `__getattribute__` and -`__call__`: - -- operators such as `__gt__`, `__add__`, etc. -- the builtin functions `__abs__` and `__round__` -- `__getitem__` -- `__array_ufunc__` - -The `_depth` attribute starts at 0 and is incremented by 1 everytime -a new `Interactive` instance is created part of a chain. -The root instance in an expression has a `_depth` of 0. An expression can -consist of multiple chains, such as `dfi[dfi.A > 1]`, as the `Interactive` -instance is referenced twice in the expression. As a consequence `_depth` -is not the total count of `Interactive` instance creations of a pipeline, -it is the count of instances created in the outer chain. In the example, that -would be `dfi[]`. `Interactive` instances don't have references about -the instances that created them or that they create, they just know their -current location in a chain thanks to `_depth`. However, as some parameters -need to be passed down the whole pipeline, they do have to propagate. E.g. -in `dfi.interactive(width=200)`, `width=200` will be propagated as `kwargs`. - -Recording the operations applied to an object in a pipeline is done -by gradually building a so-called "dim expression", or "dim transform", -which is an expression language provided by HoloViews. dim transform -objects are a way to express transforms on `Dataset`s, a `Dataset` being -another HoloViews object that is a wrapper around common data structures -such as Pandas/Dask/... Dataframes/Series, Xarray Dataset/DataArray, etc. -For instance a Python expression such as `(series + 2).head()` can be -expressed with a dim transform whose repr will be `(dim('*').pd+2).head(2)`, -effectively showing that the dim transfom has recorded the different -operations that are meant to be applied to the data. -The `_transform` attribute stores the dim transform. - -The `_obj` attribute holds the original data structure that feeds the -pipeline. All the `Interactive` instances created while parsing the -pipeline share the same `_obj` object. And they all wrap it in a `Dataset` -instance, and all apply the current dim transform they are aware of to -the original data structure to compute the intermediate state of the data, -that is stored it in the `_current_` attribute. Doing so is particularly -useful in Notebook sessions, as this allows to inspect the transformed -object at any point of the pipeline, and as such provide correct -auto-completion and docstrings. E.g. executing `dfi.A.max?` in a Notebook -will correctly return the docstring of the Pandas Series `.max()` method, -as the pipeline evaluates `dfi.A` to hold a current object `_current` that -is a Pandas Series, and no longer and DataFrame. - -The `_obj` attribute is implemented as a property which gets/sets the value -from a list that contains the shared attribute. This is required for the -"function as input" to be able to update the object from a callback set up -on the root Interactive instance. - -Internally interactive holds the current evaluated state on the `_current_` -attribute, when some parameter in the interactive pipeline is changed -the pipeline is marked as `_dirty`. This means that the next time `_current` -is accessed the pipeline will be re-evaluated to get the up-to-date -current value. - -The `_method` attribute is a string that temporarily stores the method/attr -accessed on the object, e.g. `_method` is 'head' in `dfi.head()`, until the -Interactive instance created in the pipeline is called at which point `_method` -is reset to None. In cases such as `dfi.head` or `dfi.A`, `_method` is not -(yet) reset to None. At this stage the Interactive instance returned has -its `_current` attribute not updated, e.g. `dfi.A._current` is still the -original dataframe, not the 'A' series. Keeping `_method` is thus useful for -instance to display `dfi.A`, as the evaluation of the object will check -whether `_method` is set or not, and if it's set it will use it to compute -the object returned, e.g. the series `df.A` or the method `df.head`, and -display its repr. +`Interactive` is a wrapper around a Python object that lets users +create interactive pipelines by calling existing APIs on an object +with dynamic parameters or widgets. An `Interactive` instance watches +what operations are applied to the object. """ -import abc -import operator -import sys - from functools import partial -from packaging.version import Version -from types import FunctionType, MethodType import holoviews as hv -import pandas as pd -import panel as pn -import param -from panel.layout import Column, Row, VSpacer, HSpacer -from panel.util import get_method_owner, full_groupby -from panel.widgets.base import Widget +from panel.react import react from .converter import HoloViewsConverter -from .util import ( - _flatten, bokeh3, is_tabular, is_xarray, is_xarray_dataarray, - _convert_col_names_to_str, -) +from .util import is_tabular, is_xarray -def _find_widgets(op): - widgets = [] - op_args = list(op['args']) + list(op['kwargs'].values()) - op_args = _flatten(op_args) - for op_arg in op_args: - # Find widgets introduced as `widget` in an expression - if isinstance(op_arg, Widget) and op_arg not in widgets: - widgets.append(op_arg) - # TODO: Find how to execute this path? - if isinstance(op_arg, hv.dim): - for nested_op in op_arg.ops: - for widget in _find_widgets(nested_op): - if widget not in widgets: - widgets.append(widget) - # Find Ipywidgets - if 'ipywidgets' in sys.modules: - from ipywidgets import Widget as IPyWidget - if isinstance(op_arg, IPyWidget) and op_arg not in widgets: - widgets.append(op_arg) - # Find widgets introduced as `widget.param.value` in an expression - if (isinstance(op_arg, param.Parameter) and - isinstance(op_arg.owner, pn.widgets.Widget) and - op_arg.owner not in widgets): - widgets.append(op_arg.owner) - if isinstance(op_arg, slice): - if Version(hv.__version__) < Version("1.15.1"): - raise ValueError( - "Using interactive with slices needs to have " - "Holoviews 1.15.1 or greater installed." - ) - nested_op = {"args": [op_arg.start, op_arg.stop, op_arg.step], "kwargs": {}} - for widget in _find_widgets(nested_op): - if widget not in widgets: - widgets.append(widget) - return widgets - - -class Interactive: +class Interactive(react): """ The `.interactive` API enhances the API of data analysis libraries like Pandas, Dask, and Xarray, by allowing to replace in a pipeline @@ -206,191 +69,7 @@ class Interactive: >>> dfi.head(widget) """ - # TODO: Why? - __metaclass__ = abc.ABCMeta - - # Hackery to support calls to the classic `.plot` API, see `_get_ax_fn` - # for more hacks! - _fig = None - - def __new__(cls, obj, **kwargs): - # __new__ implemented to support functions as input, e.g. - # hvplot.find(foo, widget).interactive().max() - if 'fn' in kwargs: - fn = kwargs.pop('fn') - elif isinstance(obj, (FunctionType, MethodType)): - fn = pn.panel(obj, lazy=True) - obj = fn.eval(obj) - else: - fn = None - clss = cls - for subcls in cls.__subclasses__(): - if subcls.applies(obj): - clss = subcls - inst = super(Interactive, cls).__new__(clss) - inst._shared_obj = kwargs.get('_shared_obj', [obj]) - inst._fn = fn - return inst - - @classmethod - def applies(cls, obj): - """ - Subclasses must implement applies and return a boolean to indicate - wheter the subclass should apply or not to the obj. - """ - return True - - def __init__(self, obj, transform=None, fn=None, plot=False, depth=0, - loc='top_left', center=False, dmap=False, inherit_kwargs={}, - max_rows=100, method=None, _shared_obj=None, _current=None, **kwargs): - - # _init is used to prevent to __getattribute__ to execute its - # specialized code. - self._init = False - self._method = method - if transform is None: - dim = '*' - transform = hv.util.transform.dim - if is_xarray(obj): - transform = hv.util.transform.xr_dim - if is_xarray_dataarray(obj): - dim = obj.name - if dim is None: - raise ValueError( - "Cannot use interactive API on DataArray without name." - "Assign a name to the DataArray and try again." - ) - elif is_tabular(obj): - transform = hv.util.transform.df_dim - self._transform = transform(dim) - else: - self._transform = transform - self._plot = plot - self._depth = depth - self._loc = loc - self._center = center - self._dmap = dmap - # TODO: What's the real use of inherit_kwargs? So far I've only seen - # it containing 'ax' - self._inherit_kwargs = inherit_kwargs - self._max_rows = max_rows - self._kwargs = kwargs - ds = hv.Dataset(_convert_col_names_to_str(self._obj)) - if _current is not None: - self._current_ = _current - else: - self._current_ = self._transform.apply(ds, keep_index=True, compute=False) - self._init = True - self._dirty = False - self.hvplot = _hvplot(self) - self._setup_invalidations(depth) - - @property - def _obj(self): - return self._shared_obj[0] - - @_obj.setter - def _obj(self, obj): - if self._shared_obj is None: - self._shared_obj = [obj] - else: - self._shared_obj[0] = obj - - @property - def _current(self): - if self._dirty: - self.eval() - return self._current_ - - @property - def _fn_params(self): - if self._fn is None: - deps = [] - elif isinstance(self._fn, pn.param.ParamFunction): - dinfo = getattr(self._fn.object, '_dinfo', {}) - deps = list(dinfo.get('dependencies', [])) + list(dinfo.get('kw', {}).values()) - else: - # TODO: Find how to execute that path? - parameterized = get_method_owner(self._fn.object) - deps = parameterized.param.method_dependencies(self._fn.object.__name__) - return deps - - @property - def _params(self): - ps = self._fn_params - for k, p in self._transform.params.items(): - if k == 'ax' or p in ps: - continue - ps.append(p) - return ps - - def _setup_invalidations(self, depth=0): - """ - Since the parameters of the pipeline can change at any time - we have to invalidate the internal state of the pipeline. - To handle both invalidations of the inputs of the pipeline - and the pipeline itself we set up watchers on both. - - 1. The first invalidation we have to set up is to re-evaluate - the function that feeds the pipeline. Only the root node of - a pipeline has to perform this invalidation because all - leaf nodes inherit the same shared_obj. This avoids - evaluating the same function for every branch of the pipeline. - 2. The second invalidation is for the pipeline itself, i.e. - if any parameter changes we have to notify the pipeline that - it has to re-evaluate the pipeline. This is done by marking - the pipeline as `_dirty`. The next time the `_current` value - is requested we then run and `.eval()` pass that re-executes - the pipeline. - """ - if self._fn is not None and depth == 0: - for _, params in full_groupby(self._fn_params, lambda x: id(x.owner)): - params[0].owner.param.watch(self._update_obj, [p.name for p in params]) - for _, params in full_groupby(self._params, lambda x: id(x.owner)): - params[0].owner.param.watch(self._invalidate_current, [p.name for p in params]) - - def _invalidate_current(self, *events): - self._dirty = True - - def _update_obj(self, *args): - self._obj = self._fn.eval(self._fn.object) - - @property - def _callback(self): - def evaluate_inner(): - obj = self.eval() - if isinstance(obj, pd.DataFrame): - return pn.pane.DataFrame(obj, max_rows=self._max_rows, **self._kwargs) - return obj - params = self._params - if params: - @pn.depends(*params) - def evaluate(*args, **kwargs): - return evaluate_inner() - else: - def evaluate(): - return evaluate_inner() - return evaluate - - def _clone(self, transform=None, plot=None, loc=None, center=None, - dmap=None, copy=False, max_rows=None, **kwargs): - plot = self._plot or plot - transform = transform or self._transform - loc = self._loc if loc is None else loc - center = self._center if center is None else center - dmap = self._dmap if dmap is None else dmap - max_rows = self._max_rows if max_rows is None else max_rows - depth = self._depth + 1 - if copy: - kwargs = dict(self._kwargs, _current=self._current, inherit_kwargs=self._inherit_kwargs, method=self._method, **kwargs) - else: - kwargs = dict(self._inherit_kwargs, **dict(self._kwargs, **kwargs)) - return type(self)(self._obj, fn=self._fn, transform=transform, plot=plot, depth=depth, - loc=loc, center=center, dmap=dmap, _shared_obj=self._shared_obj, - max_rows=max_rows, **kwargs) - - def _repr_mimebundle_(self, include=[], exclude=[]): - return self.layout()._repr_mimebundle_() + _display_options = ('center', 'dmap', 'loc') def __dir__(self): current = self._current @@ -404,269 +83,6 @@ def __dir__(self): except Exception: return sorted(set(dir(type(self))) | set(self.__dict__) | extras) - def _resolve_accessor(self): - if not self._method: - # No method is yet set, as in `dfi.A`, so return a copied clone. - return self._clone(copy=True) - # This is executed when one runs e.g. `dfi.A > 1`, in which case after - # dfi.A the _method 'A' is set (in __getattribute__) which allows - # _resolve_accessor to keep building the transform dim expression. - transform = type(self._transform)(self._transform, self._method, accessor=True) - transform._ns = self._current - inherit_kwargs = {} - if self._method == 'plot': - inherit_kwargs['ax'] = self._get_ax_fn() - try: - new = self._clone(transform, inherit_kwargs=inherit_kwargs) - finally: - # Reset _method for whatever happens after the accessor has been - # fully resolved, e.g. whatever happens `dfi.A > 1`. - self._method = None - return new - - def __getattribute__(self, name): - self_dict = super().__getattribute__('__dict__') - if not self_dict.get('_init'): - return super().__getattribute__(name) - - current = self_dict['_current_'] - method = self_dict['_method'] - if method: - current = getattr(current, method) - # Getting all the public attributes available on the current object, - # e.g. `sum`, `head`, etc. - extras = [d for d in dir(current) if not d.startswith('_')] - if name in extras and name not in super().__dir__(): - new = self._resolve_accessor() - # Setting the method name for a potential use later by e.g. an - # operator or method, as in `dfi.A > 2`. or `dfi.A.max()` - new._method = name - try: - new.__doc__ = getattr(current, name).__doc__ - except Exception: - pass - return new - return super().__getattribute__(name) - - @staticmethod - def _get_ax_fn(): - @pn.depends() - def get_ax(): - from matplotlib.backends.backend_agg import FigureCanvas - from matplotlib.pyplot import Figure - Interactive._fig = fig = Figure() - FigureCanvas(fig) - return fig.subplots() - return get_ax - - def __call__(self, *args, **kwargs): - """ - The `.interactive` API enhances the API of data analysis libraries - like Pandas, Dask, and Xarray, by allowing to replace in a pipeline - static values by dynamic widgets. When displayed, an interactive - pipeline will incorporate the dynamic widgets that control it, as long - as its normal output that will automatically be updated as soon as a - widget value is changed. - - Reference: https://hvplot.holoviz.org/user_guide/Interactive.html - - Parameters - ---------- - loc : str, optional - Widget(s) location, one of 'bottom_left', 'bottom_right', 'right' - 'top-right', 'top-left' and 'left'. By default 'top_left' - center : bool, optional - Whether to center to pipeline output, by default False - max_rows : int, optional - Maximum number of rows displayed, only used when the output is a - dataframe, by default 100 - kwargs: optional - Optional kwargs that are passed down to customize the displayed - object. E.g. if the output is a DataFrame `width=200` will set - the size of the DataFrame Pane that renders it. - - Returns - ------- - Interactive - The next `Interactive` object of the pipeline. - - Examples - -------- - >>> widget = panel.wid7ugets.IntSlider(value=1, start=1, end=5) - >>> dfi = df.interactive(width=200) - >>> dfi.head(widget) - """ - - if self._method is None: - if self._depth == 0: - # This code path is entered when initializing an interactive - # class from the accessor, e.g. with df.interactive(). As - # calling the accessor df.interactive already returns an - # Interactive instance. - return self._clone(*args, **kwargs) - # TODO: When is this error raised? - raise AttributeError - elif self._method == 'plot': - # This - {ax: get_ax} - is passed as kwargs to the plot method in - # the dim expression. - kwargs['ax'] = self._get_ax_fn() - new = self._clone(copy=True) - try: - method = type(new._transform)(new._transform, new._method, accessor=True) - kwargs = dict(new._inherit_kwargs, **kwargs) - clone = new._clone(method(*args, **kwargs), plot=new._method == 'plot') - finally: - # If an error occurs reset _method anyway so that, e.g. the next - # attempt in a Notebook, is set appropriately. - new._method = None - return clone - - #---------------------------------------------------------------- - # Interactive pipeline APIs - #---------------------------------------------------------------- - - def __array_ufunc__(self, *args, **kwargs): - # TODO: How to trigger this method? - new = self._resolve_accessor() - transform = new._transform - transform = args[0](transform, *args[3:], **kwargs) - return new._clone(transform) - - def _apply_operator(self, operator, *args, reverse=False, **kwargs): - new = self._resolve_accessor() - transform = new._transform - transform = type(transform)(transform, operator, *args, reverse=reverse) - return new._clone(transform) - - # Builtin functions - - def __abs__(self): - return self._apply_operator(abs) - - def __round__(self, ndigits=None): - args = () if ndigits is None else (ndigits,) - return self._apply_operator(round, *args) - - # Unary operators - def __neg__(self): - return self._apply_operator(operator.neg) - def __not__(self): - return self._apply_operator(operator.not_) - def __invert__(self): - return self._apply_operator(operator.inv) - def __pos__(self): - return self._apply_operator(operator.pos) - - # Binary operators - def __add__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.add, other) - def __and__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.and_, other) - def __eq__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.eq, other) - def __floordiv__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.floordiv, other) - def __ge__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.ge, other) - def __gt__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.gt, other) - def __le__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.le, other) - def __lt__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.lt, other) - def __lshift__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.lshift, other) - def __mod__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.mod, other) - def __mul__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.mul, other) - def __ne__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.ne, other) - def __or__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.or_, other) - def __rshift__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.rshift, other) - def __pow__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.pow, other) - def __sub__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.sub, other) - def __truediv__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.truediv, other) - - # Reverse binary operators - def __radd__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.add, other, reverse=True) - def __rand__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.and_, other, reverse=True) - def __rdiv__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.div, other, reverse=True) - def __rfloordiv__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.floordiv, other, reverse=True) - def __rlshift__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.rlshift, other) - def __rmod__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.mod, other, reverse=True) - def __rmul__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.mul, other, reverse=True) - def __ror__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.or_, other, reverse=True) - def __rpow__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.pow, other, reverse=True) - def __rrshift__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.rrshift, other) - def __rsub__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.sub, other, reverse=True) - def __rtruediv__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.truediv, other, reverse=True) - - def __getitem__(self, other): - other = other._transform if isinstance(other, Interactive) else other - return self._apply_operator(operator.getitem, other) - - def _plot(self, *args, **kwargs): - # TODO: Seems totally unused to me, as self._plot is set to a boolean in __init__ - @pn.depends() - def get_ax(): - from matplotlib.backends.backend_agg import FigureCanvas - from matplotlib.pyplot import Figure - Interactive._fig = fig = Figure() - FigureCanvas(fig) - return fig.subplots() - kwargs['ax'] = get_ax - new = self._resolve_accessor() - transform = new._transform - transform = type(transform)(transform, 'plot', accessor=True) - return new._clone(transform(*args, **kwargs), plot=True) - #---------------------------------------------------------------- # Public API #---------------------------------------------------------------- @@ -678,127 +94,6 @@ def dmap(self): """ return hv.DynamicMap(self._callback) - def eval(self): - """ - Returns the current state of the interactive expression. The - returned object is no longer interactive. - """ - if self._dirty: - obj = self._obj - ds = hv.Dataset(_convert_col_names_to_str(obj)) - transform = self._transform - if ds.interface.datatype == 'xarray' and is_xarray_dataarray(obj): - transform = transform.clone(obj.name) - obj = transform.apply(ds, keep_index=True, compute=False) - self._current_ = obj - self._dirty = False - else: - obj = self._current_ - - if self._method: - # E.g. `pi = dfi.A` leads to `pi._method` equal to `'A'`. - obj = getattr(obj, self._method, obj) - if self._plot: - obj = Interactive._fig - - return obj - - def layout(self, **kwargs): - """ - Returns a layout of the widgets and output arranged according - to the center and widget location specified in the - interactive call. - """ - if bokeh3: - return self._layout_bk3(**kwargs) - return self._layout_bk2(**kwargs) - - def _layout_bk2(self, **kwargs): - widget_box = self.widgets() - panel = self.output() - loc = self._loc - if loc in ('left', 'right'): - widgets = Column(VSpacer(), widget_box, VSpacer()) - elif loc in ('top', 'bottom'): - widgets = Row(HSpacer(), widget_box, HSpacer()) - elif loc in ('top_left', 'bottom_left'): - widgets = Row(widget_box, HSpacer()) - elif loc in ('top_right', 'bottom_right'): - widgets = Row(HSpacer(), widget_box) - elif loc in ('left_top', 'right_top'): - widgets = Column(widget_box, VSpacer()) - elif loc in ('left_bottom', 'right_bottom'): - widgets = Column(VSpacer(), widget_box) - # TODO: add else and raise error - center = self._center - if not widgets: - if center: - components = [HSpacer(), panel, HSpacer()] - else: - components = [panel] - elif center: - if loc.startswith('left'): - components = [widgets, HSpacer(), panel, HSpacer()] - elif loc.startswith('right'): - components = [HSpacer(), panel, HSpacer(), widgets] - elif loc.startswith('top'): - components = [HSpacer(), Column(widgets, Row(HSpacer(), panel, HSpacer())), HSpacer()] - elif loc.startswith('bottom'): - components = [HSpacer(), Column(Row(HSpacer(), panel, HSpacer()), widgets), HSpacer()] - else: - if loc.startswith('left'): - components = [widgets, panel] - elif loc.startswith('right'): - components = [panel, widgets] - elif loc.startswith('top'): - components = [Column(widgets, panel)] - elif loc.startswith('bottom'): - components = [Column(panel, widgets)] - return Row(*components, **kwargs) - - def _layout_bk3(self, **kwargs): - widget_box = self.widgets() - panel = self.output() - loc = self._loc - center = self._center - alignments = { - 'left': (Row, ('start', 'center'), True), - 'right': (Row, ('end', 'center'), False), - 'top': (Column, ('center', 'start'), True), - 'bottom': (Column, ('center', 'end'), False), - 'top_left': (Column, 'start', True), - 'top_right': (Column, ('end', 'start'), True), - 'bottom_left': (Column, ('start', 'end'), False), - 'bottom_right': (Column, 'end', False), - 'left_top': (Row, 'start', True), - 'left_bottom': (Row, ('start', 'end'), True), - 'right_top': (Row, ('end', 'start'), False), - 'right_bottom': (Row, 'end', False) - } - layout, align, widget_first = alignments[loc] - widget_box.align = align - if not len(widget_box): - if center: - components = [HSpacer(), panel, HSpacer()] - else: - components = [panel] - return Row(*components, **kwargs) - - items = (widget_box, panel) if widget_first else (panel, widget_box) - sizing_mode = kwargs.get('sizing_mode') - if not center: - if layout is Row: - components = list(items) - else: - components = [layout(*items, sizing_mode=sizing_mode)] - elif layout is Column: - components = [HSpacer(), layout(*items, sizing_mode=sizing_mode), HSpacer()] - elif loc.startswith('left'): - components = [widget_box, HSpacer(), panel, HSpacer()] - else: - components = [HSpacer(), panel, HSpacer(), widget_box] - return Row(*components, **kwargs) - def holoviews(self): """ Returns a HoloViews object to render the output of this @@ -816,35 +111,11 @@ def output(self): ------- DynamicMap or Panel object wrapping the interactive output. """ - return self.holoviews() if self._dmap else self.panel(**self._kwargs) - - def panel(self, **kwargs): - """ - Wraps the output in a Panel component. - """ - return pn.panel(self._callback, **kwargs) - - def widgets(self): - """ - Returns a Column of widgets which control the interactive output. - - Returns - ------- - A Column of widgets - """ - widgets = [] - for p in self._fn_params: - if (isinstance(p.owner, pn.widgets.Widget) and - p.owner not in widgets): - widgets.append(p.owner) - for op in self._transform.ops: - for w in _find_widgets(op): - if w not in widgets: - widgets.append(w) - return pn.Column(*widgets) + return self.holoviews() if self._display_opts.get('dmap') else self.panel(**self._kwargs) class _hvplot: + _kinds = tuple(HoloViewsConverter._kind_mapping) __slots__ = ["_interactive"] @@ -862,10 +133,13 @@ def __call__(self, *args, _kind=None, **kwargs): kwargs["kind"] = _kind new = self._interactive._resolve_accessor() - transform = new._transform - transform = type(transform)(transform, 'hvplot', accessor=True) + operation = { + 'fn': 'hvplot', + 'args': args, + 'kwargs': kwargs, + } dmap = 'kind' not in kwargs or isinstance(kwargs['kind'], str) - return new._clone(transform(*args, **kwargs), dmap=dmap) + return new._clone(operation, dmap=dmap) def __getattr__(self, attr): if attr in self._kinds: @@ -876,3 +150,8 @@ def __getattr__(self, attr): def __dir__(self): # This function is for autocompletion return self._interactive._obj.hvplot.__all__ + + +react.register_accessor( + 'hvplot', _hvplot, predicate=lambda obj: is_tabular(obj) or is_xarray(obj) +) diff --git a/hvplot/pandas.py b/hvplot/pandas.py index 7a8dda412..be5c55da8 100644 --- a/hvplot/pandas.py +++ b/hvplot/pandas.py @@ -1,4 +1,5 @@ """Adds the `.hvplot` method to pd.DataFrame and pd.Series""" + from .interactive import Interactive def patch(name='hvplot', interactive='interactive', extension='bokeh', logo=False): diff --git a/hvplot/tests/testinteractive.py b/hvplot/tests/testinteractive.py index 25607ff87..311831952 100644 --- a/hvplot/tests/testinteractive.py +++ b/hvplot/tests/testinteractive.py @@ -1,3 +1,5 @@ +import operator + from packaging.version import Version import holoviews as hv @@ -10,7 +12,8 @@ import pytest import xarray as xr -from holoviews.util.transform import dim +from panel.react import Wrapper + from hvplot import bind from hvplot.interactive import Interactive from hvplot.xarray import XArrayInteractive @@ -114,8 +117,8 @@ def test_interactive_pandas_dataframe(df): assert type(dfi) is Interactive assert dfi._obj is df - assert dfi._fn is None - assert dfi._transform == dim('*') + assert isinstance(dfi._wrapper, Wrapper) + assert dfi._operation is None assert dfi._method is None @@ -124,8 +127,8 @@ def test_interactive_pandas_series(series): assert type(si) is Interactive assert si._obj is series - assert si._fn is None - assert si._transform == dim('*') + assert isinstance(si._wrapper, Wrapper) + assert si._operation is None assert si._method is None @@ -133,16 +136,11 @@ def test_interactive_xarray_dataarray(dataarray): dai = Interactive(dataarray) assert type(dai) is XArrayInteractive - assert (dai._obj == dataarray).all() - assert dai._fn is None - assert dai._transform == dim('air') + assert dai._obj is dataarray + assert isinstance(dai._wrapper, Wrapper) + assert dai._operation is None assert dai._method is None - - -def test_interactive_xarray_dataarray_no_name(): - dataarray = xr.DataArray(np.random.rand(2, 2)) - with pytest.raises(ValueError, match='Cannot use interactive API on DataArray without name'): - Interactive(dataarray) + assert dai.eval() is dataarray def test_interactive_xarray_dataset(dataset): @@ -150,9 +148,10 @@ def test_interactive_xarray_dataset(dataset): assert type(dsi) is XArrayInteractive assert dsi._obj is dataset - assert dsi._fn is None - assert dsi._transform == dim('*') + assert isinstance(dsi._wrapper, Wrapper) + assert dsi._operation is None assert dsi._method is None + assert dsi.eval() is dataset def test_interactive_pandas_function(df): @@ -164,8 +163,8 @@ def sel_col(col): dfi = Interactive(bind(sel_col, select)) assert type(dfi) is Interactive assert dfi._obj is df.A - assert isinstance(dfi._fn, pn.param.ParamFunction) - assert dfi._transform == dim('*') + assert hasattr(dfi._fn, '_dinfo') + assert dfi._operation is None assert dfi._method is None select.value = 'B' @@ -184,13 +183,12 @@ def sel_col(sel): dsi = Interactive(bind(sel_col, select)) assert type(dsi) is XArrayInteractive - assert isinstance(dsi._fn, pn.param.ParamFunction) - assert dsi._transform == dim('air') + assert hasattr(dsi._fn, '_dinfo') + assert dsi._operation is None assert dsi._method is None select.value = 'air2' assert (dsi._obj == ds.air2).all() - assert dsi._transform == dim('air2') def test_interactive_nested_widgets(): @@ -219,7 +217,7 @@ def test_interactive_slice(): idf = Interactive(df) pipeline = idf.iloc[:w] - ioutput = pipeline.panel().object().object + ioutput = pipeline.eval() iw = pipeline.widgets() output = df.iloc[:10] @@ -229,7 +227,7 @@ def test_interactive_slice(): assert iw[0] == w w.value = 15 - ioutput = pipeline.panel().object().object + ioutput = pipeline.eval() output = df.iloc[:15] pd.testing.assert_frame_equal(ioutput, output) @@ -237,7 +235,7 @@ def test_interactive_slice(): def test_interactive_pandas_dataframe_hvplot_accessor(df): dfi = df.interactive() - assert dfi.hvplot(kind="scatter")._transform == dfi.hvplot.scatter()._transform + assert dfi.hvplot(kind="scatter")._operation == dfi.hvplot.scatter()._operation with pytest.raises(TypeError): dfi.hvplot.scatter(kind="area") @@ -246,7 +244,7 @@ def test_interactive_pandas_dataframe_hvplot_accessor(df): def test_interactive_xarray_dataset_hvplot_accessor(dataarray): dai = dataarray.interactive - assert dai.hvplot(kind="line")._transform == dai.hvplot.line()._transform + assert dai.hvplot(kind="line")._operation == dai.hvplot.line()._operation with pytest.raises(TypeError): dai.hvplot.line(kind="area") @@ -257,7 +255,7 @@ def test_interactive_pandas_dataframe_hvplot_accessor_dmap(df): dfi = dfi.hvplot.line(y='A') # TODO: Not sure about the logic - assert dfi._dmap is True + assert dfi._display_opts['dmap'] def test_interactive_pandas_dataframe_hvplot_accessor_dmap_kind_widget(df): @@ -265,7 +263,7 @@ def test_interactive_pandas_dataframe_hvplot_accessor_dmap_kind_widget(df): dfi = df.interactive() dfi = dfi.hvplot(kind=w, y='A') - assert dfi._dmap is False + assert not dfi._display_opts['dmap'] def test_interactive_with_bound_function_calls(): @@ -286,6 +284,7 @@ def load_data(species, watch=True): dfi = dfi.loc[dfi['sex'].isin(w_sex)] out = dfi.output() + out.get_root() # It's lazy so we must evaluate assert isinstance(out, pn.param.ParamFunction) assert isinstance(out._pane, pn.pane.DataFrame) @@ -311,6 +310,7 @@ def load_data(species, watch=True): assert load_data.COUNT == 2 out = dfi.output() + out.get_root() pd.testing.assert_frame_equal( out._pane.object, @@ -333,9 +333,9 @@ def test_interactive_pandas_series_init(series, clone_spy): assert clone_spy.count == 0 assert si._obj is series - assert repr(si._transform) == "dim('*')" - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series) + assert si._operation is None + assert isinstance(si._current, pd.Series) + pd.testing.assert_series_equal(si._current, series) assert si._depth == 0 assert si._method is None @@ -348,9 +348,9 @@ def test_interactive_pandas_series_accessor(series, clone_spy): assert clone_spy.calls[0].is_empty() assert si._obj is series - assert repr(si._transform) == "dim('*')" - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series) + assert si._operation is None + assert isinstance(si._current, pd.Series) + pd.testing.assert_series_equal(si._current, series) assert si._depth == 1 assert si._method is None @@ -360,10 +360,12 @@ def test_interactive_pandas_series_operator(series, clone_spy): si = si + 2 assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series + 2) + assert isinstance(si._current, pd.Series) + pd.testing.assert_series_equal(si.eval(), series + 2) assert si._obj is series - assert repr(si._transform) == "dim('*').pd+2" + assert si._operation == { + 'fn': operator.add, 'args': (2,), 'kwargs': {}, 'reverse': False + } assert si._depth == 2 assert si._method is None @@ -377,7 +379,9 @@ def test_interactive_pandas_series_operator(series, clone_spy): # _clone in _apply_operator assert clone_spy.calls[1].depth == 2 assert len(clone_spy.calls[1].args) == 1 - assert repr(clone_spy.calls[1].args[0]) == "dim('*').pd+2" + assert clone_spy.calls[1].args[0] == { + 'fn': operator.add, 'args': (2,), 'kwargs': {}, 'reverse': False + } assert not clone_spy.calls[1].kwargs @@ -386,10 +390,15 @@ def test_interactive_pandas_series_method_args(series, clone_spy): si = si.head(2) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series.head(2)) + assert isinstance(si._current, pd.Series) + pd.testing.assert_series_equal(si.eval(), series.head(2)) assert si._obj is series - assert repr(si._transform) == "dim('*').pd.head(2)" + assert si._operation == { + 'fn': 'head', + 'args': (2,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 3 assert si._method is None @@ -408,8 +417,12 @@ def test_interactive_pandas_series_method_args(series, clone_spy): # 2nd _clone in __call__ assert clone_spy.calls[2].depth == 3 assert len(clone_spy.calls[2].args) == 1 - assert repr(clone_spy.calls[2].args[0]) == "dim('*').pd.head(2)" - assert clone_spy.calls[2].kwargs == {'plot': False} + assert clone_spy.calls[2].args[0] == { + 'fn': 'head', + 'args': (2,), + 'kwargs': {}, + 'reverse': False + } def test_interactive_pandas_series_method_kwargs(series, clone_spy): @@ -417,10 +430,15 @@ def test_interactive_pandas_series_method_kwargs(series, clone_spy): si = si.head(n=2) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series.head(2)) + assert isinstance(si._current, pd.Series) + pd.testing.assert_series_equal(si.eval(), series.head(2)) assert si._obj is series - assert repr(si._transform) == "dim('*').pd.head(n=2)" + assert si._operation == { + 'fn': 'head', + 'args': (), + 'kwargs': {'n': 2}, + 'reverse': False + } assert si._depth == 3 assert si._method is None @@ -439,8 +457,12 @@ def test_interactive_pandas_series_method_kwargs(series, clone_spy): # 2nd _clone in __call__ assert clone_spy.calls[2].depth == 3 assert len(clone_spy.calls[2].args) == 1 - assert repr(clone_spy.calls[2].args[0]) == "dim('*').pd.head(n=2)" - assert clone_spy.calls[2].kwargs == {'plot': False} + assert clone_spy.calls[2].args[0] == { + 'fn': 'head', + 'args': (), + 'kwargs': {'n': 2}, + 'reverse': False + } @@ -449,10 +471,8 @@ def test_interactive_pandas_series_method_not_called(series, clone_spy): si = si.head assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, si._obj) assert si._obj is series - assert repr(si._transform) == "dim('*')" + assert si._operation is None assert si._depth == 1 assert si._method == 'head' @@ -469,10 +489,10 @@ def test_interactive_pandas_frame_attrib(df, clone_spy): dfi = dfi.A assert isinstance(dfi, Interactive) - assert isinstance(dfi._current, pd.DataFrame) - pd.testing.assert_frame_equal(dfi._current, dfi._obj) + assert isinstance(dfi.eval(), pd.Series) + pd.testing.assert_frame_equal(dfi.eval(), dfi._obj) assert dfi._obj is df - assert repr(dfi._transform) == "dim('*')" + assert dfi._operation is None assert dfi._depth == 1 assert dfi._method == 'A' @@ -490,10 +510,21 @@ def test_interactive_pandas_series_operator_and_method(series, clone_spy): si = (si + 2).head(2) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, (series + 2).head(2)) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), (series + 2).head(2)) assert si._obj is series - assert repr(si._transform) == "(dim('*').pd+2).head(2)" + assert si._prev._operation == { + 'fn': operator.add, + 'args': (2,), + 'kwargs': {}, + 'reverse': False + } + assert si._operation == { + 'fn': 'head', + 'args': (2,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 5 assert si._method is None @@ -507,7 +538,12 @@ def test_interactive_pandas_series_operator_and_method(series, clone_spy): # _clone in _apply_operator assert clone_spy.calls[1].depth == 2 assert len(clone_spy.calls[1].args) == 1 - assert repr(clone_spy.calls[1].args[0]) == "dim('*').pd+2" + assert clone_spy.calls[1].args[0] == { + 'fn': operator.add, + 'args': (2,), + 'kwargs': {}, + 'reverse': False + } assert not clone_spy.calls[1].kwargs # _clone in _resolve_accessor in __getattribute__(name='head') @@ -523,8 +559,12 @@ def test_interactive_pandas_series_operator_and_method(series, clone_spy): # 2nd _clone in __call__ assert clone_spy.calls[4].depth == 5 assert len(clone_spy.calls[4].args) == 1 - assert repr(clone_spy.calls[4].args[0]) == "(dim('*').pd+2).head(2)" - assert clone_spy.calls[4].kwargs == {'plot': False} + assert clone_spy.calls[4].args[0] == { + 'fn': 'head', + 'args': (2,), + 'kwargs': {}, + 'reverse': False + } def test_interactive_pandas_series_operator_widget(series): @@ -535,15 +575,21 @@ def test_interactive_pandas_series_operator_widget(series): si = si + w assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series + w.value) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), series + w.value) assert si._obj is series - assert repr(si._transform) == "dim('*').pd+FloatSlider(end=5.0, start=1.0, value=2.0)" + assert si._operation == { + 'fn': operator.add, + 'args': (w,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 2 assert si._method is None - assert len(si._params) == 1 - assert si._params[0] is w.param.value + assert len(si._params) == 2 + assert si._params[0] is si._wrapper.param.object + assert si._params[1] is w.param.value def test_interactive_pandas_series_method_widget(series): @@ -554,15 +600,21 @@ def test_interactive_pandas_series_method_widget(series): si = si.head(w) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series.head(w.value)) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), series.head(w.value)) assert si._obj is series - assert repr(si._transform) == "dim('*').pd.head(IntSlider(end=5, start=1, value=2))" + assert si._operation == { + 'fn': 'head', + 'args': (w,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 3 assert si._method is None - assert len(si._params) == 1 - assert si._params[0] is w.param.value + assert len(si._params) == 2 + assert si._params[0] is si._wrapper.param.object + assert si._params[1] is w.param.value def test_interactive_pandas_series_operator_and_method_widget(series): @@ -574,16 +626,28 @@ def test_interactive_pandas_series_operator_and_method_widget(series): si = (si + w1).head(w2) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, (series + w1.value).head(w2.value)) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), (series + w1.value).head(w2.value)) assert si._obj is series - assert repr(si._transform) == "(dim('*').pd+FloatSlider(end=5.0, start=1.0, value=2.0)).head(IntSlider(end=5, start=1, value=2))" + assert si._prev._operation == { + 'fn': operator.add, + 'args': (w1,), + 'kwargs': {}, + 'reverse': False + } + assert si._operation == { + 'fn': 'head', + 'args': (w2,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 5 assert si._method is None - assert len(si._params) == 2 - assert si._params[0] is w1.param.value - assert si._params[1] is w2.param.value + assert len(si._params) == 3 + assert si._params[0] is si._wrapper.param.object + assert si._params[1] is w1.param.value + assert si._params[2] is w2.param.value def test_interactive_pandas_series_operator_ipywidgets(series): @@ -596,15 +660,22 @@ def test_interactive_pandas_series_operator_ipywidgets(series): si = si + w assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series + w.value) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), series + w.value) assert si._obj is series - assert repr(si._transform) == "dim('*').pd+FloatSlider(value=2.0, max=5.0, min=1.0)" + assert si._operation == { + 'fn': operator.add, + 'args': (w,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 2 assert si._method is None - # TODO: Isn't that a bug? - assert len(si._params) == 0 + assert len(si._params) == 2 + assert si._params[0] is si._wrapper.param.object + # Check for parameter created to wrap ipywidget + assert type(si._params[1].owner).__name__ == 'FloatSlider' widgets = si.widgets() @@ -693,8 +764,12 @@ def test_interactive_reevaluate_uses_cached_value(series): si = si + w w.value = 3. - assert repr(si._transform) == "dim('*').pd+FloatSlider(end=5.0, start=1.0, value=3.0)" - + assert si._operation == { + 'fn': operator.add, + 'args': (w,), + 'kwargs': {}, + 'reverse': False + } assert si._callback().object is si._callback().object @@ -704,12 +779,17 @@ def test_interactive_pandas_series_operator_widget_update(series): si = si + w w.value = 3. - assert repr(si._transform) == "dim('*').pd+FloatSlider(end=5.0, start=1.0, value=3.0)" + assert si._operation == { + 'fn': operator.add, + 'args': (w,), + 'kwargs': {}, + 'reverse': False + } out = si._callback() assert out.object is si.eval() assert isinstance(out, pn.pane.DataFrame) - pd.testing.assert_series_equal(out.object.A, series + 3.) + pd.testing.assert_series_equal(out.object, series + 3.) def test_interactive_pandas_series_method_widget_update(series): @@ -718,12 +798,17 @@ def test_interactive_pandas_series_method_widget_update(series): si = si.head(w) w.value = 3 - assert repr(si._transform) =="dim('*').pd.head(IntSlider(end=5, start=1, value=3))" + assert si._operation == { + 'fn': 'head', + 'args': (w,), + 'kwargs': {}, + 'reverse': False + } out = si._callback() assert out.object is si.eval() assert isinstance(out, pn.pane.DataFrame) - pd.testing.assert_series_equal(out.object.A, series.head(3)) + pd.testing.assert_series_equal(out.object, series.head(3)) def test_interactive_pandas_series_operator_and_method_widget_update(series): @@ -735,12 +820,23 @@ def test_interactive_pandas_series_operator_and_method_widget_update(series): w1.value = 3. w2.value = 3 - assert repr(si._transform) == "(dim('*').pd+FloatSlider(end=5.0, start=1.0, value=3.0)).head(IntSlider(end=5, start=1, value=3))" + assert si._prev._operation == { + 'fn': operator.add, + 'args': (w1,), + 'kwargs': {}, + 'reverse': False + } + assert si._operation == { + 'fn': 'head', + 'args': (w2,), + 'kwargs': {}, + 'reverse': False + } out = si._callback() assert out.object is si.eval() assert isinstance(out, pn.pane.DataFrame) - pd.testing.assert_series_equal(out.object.A, (series + 3.).head(3)) + pd.testing.assert_series_equal(out.object, (series + 3.).head(3)) def test_interactive_pandas_frame_loc(df): @@ -751,9 +847,21 @@ def test_interactive_pandas_frame_loc(df): assert isinstance(dfi, Interactive) assert dfi._obj is df - assert isinstance(dfi._current, pd.Series) - pd.testing.assert_series_equal(dfi._current, df.loc[:, 'A']) - assert repr(dfi._transform) == "dim('*').pd.loc, getitem, (slice(None, None, None), 'A')" + assert isinstance(dfi.eval(), pd.Series) + pd.testing.assert_series_equal(dfi.eval(), df.loc[:, 'A']) + + assert dfi._prev._operation == { + 'fn': getattr, + 'args': ('loc',), + 'kwargs': {}, + 'reverse': False + } + assert dfi._operation == { + 'fn': operator.getitem, + 'args': ((slice(None, None, None), 'A'),), + 'kwargs': {}, + 'reverse': False + } assert dfi._depth == 3 assert dfi._method is None @@ -766,9 +874,21 @@ def test_interactive_pandas_frame_filtering(df, clone_spy): assert isinstance(dfi, Interactive) assert dfi._obj is df - assert isinstance(dfi._current, pd.DataFrame) - pd.testing.assert_frame_equal(dfi._current, df[df.A > 1]) - assert repr(dfi._transform) == "dim('*', getitem, dim('*').pd.A)>1" + assert isinstance(dfi.eval(), pd.DataFrame) + pd.testing.assert_frame_equal(dfi.eval(), df[df.A > 1]) + assert dfi._operation['args'][0]._prev._operation == { + 'fn': getattr, + 'args': ('A',), + 'kwargs': {}, + 'reverse': False + } + assert dfi._operation['args'][0]._operation == { + 'fn': operator.gt, + 'args': (1,), + 'kwargs': {}, + 'reverse': False + } + assert dfi._operation['fn'] == operator.getitem # The depth of that Interactive instance is 2 because the last part of # the chain executed, i.e. dfi[], leads to two clones being created, # incrementing the _depth up to 2. @@ -785,13 +905,22 @@ def test_interactive_pandas_frame_filtering(df, clone_spy): # _clone in _resolve_accessor in _apply_operator(__gt__) assert clone_spy.calls[1].depth == 2 assert len(clone_spy.calls[1].args) == 1 - assert repr(clone_spy.calls[1].args[0]) == "dim('*').pd.A()" - assert clone_spy.calls[1].kwargs == {'inherit_kwargs': {}} + assert clone_spy.calls[1].args[0] == { + 'fn': getattr, + 'args': ('A',), + 'kwargs': {}, + 'reverse': False + } # _clone in _apply_operator(__gt__) assert clone_spy.calls[2].depth == 3 assert len(clone_spy.calls[2].args) == 1 - assert repr(clone_spy.calls[2].args[0]) == "(dim('*').pd.A())>1" + assert clone_spy.calls[2].args[0] == { + 'fn': operator.gt, + 'args': (1,), + 'kwargs': {}, + 'reverse': False + } assert not clone_spy.calls[2].kwargs # _clone(True) in _resolve_accessor in _apply_operator(getitem) @@ -802,7 +931,12 @@ def test_interactive_pandas_frame_filtering(df, clone_spy): # _clone in _apply_operator(getitem) assert clone_spy.calls[4].depth == 2 assert len(clone_spy.calls[4].args) == 1 - assert repr(clone_spy.calls[4].args[0]) == "dim('*', getitem, (dim('*').pd.A())>1)" + assert clone_spy.calls[4].args[0] == { + 'fn': operator.getitem, + 'args': (dfi._operation['args'][0],), + 'kwargs': {}, + 'reverse': False + } assert not clone_spy.calls[4].kwargs @@ -814,10 +948,22 @@ def test_interactive_pandas_frame_chained_attrs(df, clone_spy): assert isinstance(dfi, Interactive) assert dfi._obj is df - assert isinstance(dfi._current, float) - assert dfi._current == pytest.approx(df.A.max()) - # This is a weird repr! Bug? - assert repr(dfi._transform) == "dim('*').pd.A).max(" + assert isinstance(dfi.eval(), float) + assert dfi.eval() == pytest.approx(df.A.max()) + + assert dfi._prev._operation == { + 'fn': getattr, + 'args': ('A',), + 'kwargs': {}, + 'reverse': False + } + assert dfi._operation == { + 'fn': 'max', + 'args': (), + 'kwargs': {}, + 'reverse': False + } + assert dfi._depth == 4 assert dfi._method is None @@ -831,8 +977,12 @@ def test_interactive_pandas_frame_chained_attrs(df, clone_spy): # _clone(True) in _resolve_accessor in __getattribute__(name='max') assert clone_spy.calls[1].depth == 2 assert len(clone_spy.calls[1].args) == 1 - assert repr(clone_spy.calls[1].args[0]) == "dim('*').pd.A()" - assert clone_spy.calls[1].kwargs == {'inherit_kwargs': {}} + assert clone_spy.calls[1].args[0] == { + 'fn': getattr, + 'args': ('A',), + 'kwargs': {}, + 'reverse': False + } # 1st _clone(copy=True) in __call__ assert clone_spy.calls[2].depth == 3 @@ -842,8 +992,12 @@ def test_interactive_pandas_frame_chained_attrs(df, clone_spy): # 2nd _clone in __call__ assert clone_spy.calls[3].depth == 4 assert len(clone_spy.calls[3].args) == 1 - assert repr(clone_spy.calls[3].args[0]) == "(dim('*').pd.A()).max()" - assert clone_spy.calls[3].kwargs == {'plot': False} + assert clone_spy.calls[3].args[0] == { + 'fn': 'max', + 'args': (), + 'kwargs': {}, + 'reverse': False + } def test_interactive_pandas_out_repr(series): @@ -851,10 +1005,15 @@ def test_interactive_pandas_out_repr(series): si = si.max() assert isinstance(si, Interactive) - assert isinstance(si._current, pd.Series) - assert si._current.A == pytest.approx(series.max()) + assert isinstance(si.eval(), float) + assert si.eval() == pytest.approx(series.max()) assert si._obj is series - assert repr(si._transform) == "dim('*').pd.max()" + assert si._operation == { + 'fn': 'max', + 'args': (), + 'kwargs': {}, + 'reverse': False + } # One _clone from _resolve_accessor, two from _clone assert si._depth == 3 assert si._method is None @@ -862,16 +1021,16 @@ def test_interactive_pandas_out_repr(series): # Equivalent to eval out = si._callback() - assert isinstance(out, pd.Series) - assert out.A == pytest.approx(series.max()) + assert isinstance(out, float) + assert out == pytest.approx(series.max()) def test_interactive_xarray_dataarray_out_repr(dataarray): dai = Interactive(dataarray) - assert isinstance(dai._current, xr.DataArray) + assert isinstance(dai.eval(), xr.DataArray) assert dai._obj is dataarray - assert repr(dai._transform) == "dim('air')" + assert dai._operation is None assert dai._depth == 0 assert dai._method is None @@ -886,10 +1045,15 @@ def test_interactive_pandas_out_frame(series): si = si.head(2) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series.head(2)) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), series.head(2)) assert si._obj is series - assert repr(si._transform) == "dim('*').pd.head(2)" + assert si._operation == { + 'fn': 'head', + 'args': (2,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 3 assert si._method is None @@ -897,7 +1061,7 @@ def test_interactive_pandas_out_frame(series): out = si._callback() assert isinstance(out, pn.pane.DataFrame) - pd.testing.assert_frame_equal(out.object, si._current) + pd.testing.assert_series_equal(out.object, si._current) def test_interactive_pandas_out_frame_max_rows(series): @@ -955,118 +1119,142 @@ def test_interactive_pandas_out_frame_attrib(df): # In that case the default behavior is to return the object transformed # and to which _method is applied - assert isinstance(out, pd.Series) - pd.testing.assert_series_equal(df.A, out) + assert isinstance(out, pn.pane.DataFrame) + pd.testing.assert_series_equal(out.object.A, df.A) @pytest.mark.parametrize('op', [ - '-', # __neg__ - - # Doesn't any of the supported data implement __not__? - # e.g. `not series` raises an error. - # 'not', # __not__ - - '+', # _pos__ + operator.pos, operator.neg, operator.invert ]) def test_interactive_pandas_series_operator_unary(series, op): - if op == '~': + if op == operator.invert: series = pd.Series([True, False, True], name='A') si = Interactive(series) - si = eval(f'{op} si') + si = op(si) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, eval(f'{op} series')) + assert isinstance(si._current, pd.Series) + pd.testing.assert_series_equal(si._current, op(series)) assert si._obj is series - assert repr(si._transform) == f"{op}dim('*')" + assert si._operation == { + 'fn': operator.inv if op is operator.invert else op, + 'args': (), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 2 assert si._method is None -def test_interactive_pandas_series_operator_unary_invert(): # __invert__ - series = pd.Series([True, False, True], name='A') +@pytest.mark.parametrize('op', [ + operator.add, + operator.and_, + operator.eq, + operator.floordiv, + operator.ge, + operator.gt, + operator.le, + operator.lt, + operator.mod, + operator.ne, + operator.or_, + operator.pow, + operator.sub, + operator.truediv +]) +def test_interactive_pandas_series_operator_binary(series, op): + if op in [operator.and_, operator.or_]: + series = pd.Series([True, False, True], name='A') + val = True + else: + val = 2. si = Interactive(series) - si = ~si + si = op(si, val) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, ~series) + assert isinstance(si._current, pd.Series) + pd.testing.assert_series_equal(si.eval(), op(series, val)) assert si._obj is series - assert repr(si._transform) == "dim('*', inv)" + assert si._operation == { + 'fn': op, + 'args': (val,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 2 assert si._method is None @pytest.mark.parametrize('op', [ - '+', # __add__ - '&', # __and__ - '/', # __div__ - '==', # __eq__ - '//', # __floordiv__ - '>=', # __ge__ - '>', # __gt__ - '<=', # __le__ - '<', # __lt__ - '<', # __lt__ - # '<<', # __lshift__ - '%', # __mod__ - '*', # __mul__ - '!=', # __ne__ - '|', # __or__ - # '>>', # __rshift__ - '**', # __pow__ - '-', # __sub__ - '/', # __truediv__ + operator.add, + operator.and_, + operator.floordiv, + operator.mod, + operator.or_, + operator.pow, + operator.sub, + operator.truediv ]) -def test_interactive_pandas_series_operator_binary(series, op): - if op in ['&', '|']: +def test_interactive_pandas_series_operator_reverse_binary(op): + if op in [operator.and_, operator.or_]: series = pd.Series([True, False, True], name='A') val = True else: + series = pd.Series([1.0, 2.0, 3.0], name='A') val = 2. si = Interactive(series) - si = eval(f'si {op} {val}') + si = op(val, si) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, eval(f'series {op} {val}')) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), op(val, series)) assert si._obj is series - val_repr = '2.0' if isinstance(val, float) else 'True' - assert repr(si._transform) == f"dim('*').pd{op}{val_repr}" + assert si._operation == { + 'fn': op, + 'args': (val,), + 'kwargs': {}, + 'reverse': True + } assert si._depth == 2 assert si._method is None @pytest.mark.parametrize('op', [ - '+', # __radd__ - '&', # __rand__ - '/', # __rdiv__ - '//', # __rfloordiv__ - # '<<', # __rlshift__ - '%', # __rmod__ - '*', # __rmul__ - '|', # __ror__ - '**', # __rpow__ - # '>>', # __rshift__ - '-', # __rsub__ - '/', # __rtruediv__ + operator.eq, + operator.ge, + operator.gt, + operator.le, + operator.lt, + operator.ne, ]) -def test_interactive_pandas_series_operator_reverse_binary(op): - if op in ['&', '|']: +def test_interactive_pandas_series_operator_reverse_binary_comparison(op): + if op in [operator.and_, operator.or_]: series = pd.Series([True, False, True], name='A') val = True else: series = pd.Series([1.0, 2.0, 3.0], name='A') val = 2. si = Interactive(series) - si = eval(f'{val} {op} si') + si = op(val, si) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, eval(f'{val} {op} series')) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), op(val, series)) assert si._obj is series - val_repr = '2.0' if isinstance(val, float) else 'True' - assert repr(si._transform) == f"{val_repr}{op}dim('*')" + + + inverse_op = { + operator.ge: operator.le, + operator.gt: operator.lt, + operator.le: operator.ge, + operator.lt: operator.gt + }.get(op, op) + assert si._operation == { + 'fn': inverse_op, + 'args': (val,), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 2 assert si._method is None @@ -1076,10 +1264,15 @@ def test_interactive_pandas_series_operator_abs(series): si = abs(si) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, abs(series)) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), abs(series)) assert si._obj is series - assert repr(si._transform) == "absdim('*')" + assert si._operation == { + 'fn': abs, + 'args': (), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 2 assert si._method is None @@ -1089,10 +1282,15 @@ def test_interactive_pandas_series_operator_round(series): si = round(si) assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, round(series)) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), round(series)) assert si._obj is series - assert repr(si._transform) == "dim('*', round)" + assert si._operation == { + 'fn': round, + 'args': (), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 2 assert si._method is None @@ -1103,9 +1301,11 @@ def test_interactive_pandas_series_plot(series, clone_spy): si = si.plot() assert isinstance(si, Interactive) - assert isinstance(si._current, matplotlib.axes.Axes) + assert isinstance(si.eval(), matplotlib.figure.Figure) assert si._obj is series - assert "dim('*').pd.plot(ax=.get_ax" in repr(si._transform) + assert si._operation['fn'] == 'plot' + assert si._operation['args'] == () + assert 'ax' in si._operation['kwargs'] assert si._depth == 3 assert si._method is None @@ -1126,10 +1326,10 @@ def test_interactive_pandas_series_plot(series, clone_spy): assert len(clone_spy.calls[2].args) == 1 # Not the complete repr as the function doesn't have a nice repr, # its repr displays the memory address. - assert "dim('*').pd.plot(ax=.get_ax" in repr(clone_spy.calls[2].args[0]) - assert clone_spy.calls[2].kwargs == {'plot': True} + assert clone_spy.calls[2].args[0]['fn'] == 'plot' + assert clone_spy.calls[2].args[0]['args'] == () + assert 'ax' in clone_spy.calls[2].args[0]['kwargs'] - assert not si._dmap assert isinstance(si._fig, matplotlib.figure.Figure) # Just test that it doesn't raise any error. @@ -1143,11 +1343,15 @@ def test_interactive_pandas_series_plot_kind_attr(series, clone_spy): si = si.plot.line() - assert isinstance(si, Interactive) - assert isinstance(si._current, matplotlib.axes.Axes) + assert isinstance(si.eval(), matplotlib.axes.Axes) assert si._obj is series - # assert "dim('*').pd.plot).line(ax=.get_ax" in repr(si._transform) + assert si._operation == { + 'fn': 'line', + 'args': (), + 'kwargs': {}, + 'reverse': False + } assert si._depth == 4 assert si._method is None @@ -1163,8 +1367,6 @@ def test_interactive_pandas_series_plot_kind_attr(series, clone_spy): assert len(clone_spy.calls[1].args) == 1 # assert repr(clone_spy.calls[1].args[0]) == "dim('*').pd.plot()" assert len(clone_spy.calls[1].kwargs) == 1 - assert 'inherit_kwargs' in clone_spy.calls[1].kwargs - assert 'ax' in clone_spy.calls[1].kwargs['inherit_kwargs'] # 1st _clone(copy=True) in __call__ assert clone_spy.calls[2].depth == 3 @@ -1232,9 +1434,6 @@ def test_interactive_pandas_layout_default_no_widgets(df): dfi = Interactive(df) dfi = dfi.head() - assert dfi._center is False - assert dfi._loc == 'top_left' - layout = dfi.layout() assert isinstance(layout, pn.Row) @@ -1251,39 +1450,12 @@ def test_interactive_pandas_layout_default_no_widgets_kwargs(df): assert layout.width == 200 -@is_bokeh2 -def test_interactive_pandas_layout_default_with_widgets(df): - w = pn.widgets.IntSlider(value=2, start=1, end=5) - dfi = Interactive(df) - dfi = dfi.head(w) - - assert dfi._center is False - assert dfi._loc == 'top_left' - - layout = dfi.layout() - - assert isinstance(layout, pn.Row) - assert len(layout) == 1 - assert isinstance(layout[0], pn.Column) - assert len(layout[0]) == 2 - assert isinstance(layout[0][0], pn.Row) - assert isinstance(layout[0][1], pn.pane.PaneBase) - assert len(layout[0][0]) == 2 - assert isinstance(layout[0][0][0], pn.Column) - assert len(layout[0][0][0]) == 1 - assert isinstance(layout[0][0][0][0], pn.widgets.Widget) - assert isinstance(layout[0][0][1], pn.layout.HSpacer) - - @is_bokeh3 def test_interactive_pandas_layout_default_with_widgets_bk3(df): w = pn.widgets.IntSlider(value=2, start=1, end=5) dfi = Interactive(df) dfi = dfi.head(w) - assert dfi._center is False - assert dfi._loc == 'top_left' - layout = dfi.layout() assert isinstance(layout, pn.Row) @@ -1295,58 +1467,6 @@ def test_interactive_pandas_layout_default_with_widgets_bk3(df): assert len(layout[0][0]) == 1 assert isinstance(layout[0][0][0], pn.widgets.IntSlider) -@is_bokeh2 -def test_interactive_pandas_layout_center_with_widgets(df): - w = pn.widgets.IntSlider(value=2, start=1, end=5) - dfi = df.interactive(center=True) - dfi = dfi.head(w) - - assert dfi._center is True - assert dfi._loc == 'top_left' - - layout = dfi.layout() - - assert isinstance(layout, pn.Row) - assert len(layout) == 3 - assert isinstance(layout[0], pn.layout.HSpacer) - assert isinstance(layout[1], pn.Column) - assert isinstance(layout[2], pn.layout.HSpacer) - assert len(layout[1]) == 2 - assert isinstance(layout[1][0], pn.Row) - assert isinstance(layout[1][1], pn.Row) - assert len(layout[1][0]) == 2 - assert len(layout[1][1]) == 3 - assert isinstance(layout[1][0][0], pn.Column) - assert len(layout[1][0][0]) == 1 - assert isinstance(layout[1][0][0][0], pn.widgets.Widget) - assert isinstance(layout[1][1][0], pn.layout.HSpacer) - assert isinstance(layout[1][1][1], pn.pane.PaneBase) - assert isinstance(layout[1][1][2], pn.layout.HSpacer) - - -@is_bokeh2 -def test_interactive_pandas_layout_loc_with_widgets(df): - w = pn.widgets.IntSlider(value=2, start=1, end=5) - dfi = df.interactive(loc='top_right') - dfi = dfi.head(w) - - assert dfi._center is False - assert dfi._loc == 'top_right' - - layout = dfi.layout() - - assert isinstance(layout, pn.Row) - assert len(layout) == 1 - assert isinstance(layout[0], pn.Column) - assert len(layout[0]) == 2 - assert isinstance(layout[0][0], pn.Row) - assert isinstance(layout[0][1], pn.pane.PaneBase) - assert len(layout[0][0]) == 2 - assert isinstance(layout[0][0][0], pn.layout.HSpacer) - assert isinstance(layout[0][0][1], pn.Column) - assert len(layout[0][0][1]) == 1 - assert isinstance(layout[0][0][1][0], pn.widgets.Widget) - def test_interactive_pandas_eval(df): dfi = Interactive(df) @@ -1379,15 +1499,21 @@ def test_interactive_pandas_series_widget_value(series): si = si + w.param.value assert isinstance(si, Interactive) - assert isinstance(si._current, pd.DataFrame) - pd.testing.assert_series_equal(si._current.A, series + w.value) + assert isinstance(si.eval(), pd.Series) + pd.testing.assert_series_equal(si.eval(), series + w.value) assert si._obj is series - assert "dim('*').pd+