Repository: holoviz/holoviews
Pull request #6579 (2025-05-20 12:13:59)

fix: pandas select with an index and a column
Fixes https://github.com/holoviz/holoviews/issues/6578


Patch:
diff --git a/.gitignore b/.gitignore
index ae3111fef..39b8da709 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@
 /doc/_build
 /doc/Reference_Manual/*
 !/doc/Reference_Manual/index.rst
+builtdocs/
 .ipynb_checkpoints
 .coverage
 .pytest_cache
diff --git a/doc/conf.py b/doc/conf.py
index 13ea6c02d..2e505e942 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -63,8 +63,16 @@ extensions += [
     'nbsite.gallery',
     'sphinx_copybutton',
     'nbsite.analytics',
+    'numpydoc',
 ]
 
+intersphinx_mapping = {
+    'panel':    ('https://panel.holoviz.org/', None),
+}
+
+numpydoc_xref_param_type = True
+numpydoc_xref_type       = True
+
 myst_enable_extensions = ["colon_fence", "deflist"]
 numpydoc_show_inherited_class_members = False
 numpydoc_class_members_toctree = False
diff --git a/holoviews/core/data/pandas.py b/holoviews/core/data/pandas.py
index fdea717ba..2f2ee32a2 100644
--- a/holoviews/core/data/pandas.py
+++ b/holoviews/core/data/pandas.py
@@ -44,7 +44,7 @@ class PandasInterface(Interface, PandasAPI):
     def applies(cls, obj):
         if not cls.loaded():
             return False
-        return isinstance(obj, (pd.DataFrame, pd.Series))
+        return type(obj) is pd.DataFrame
 
     @classmethod
     def dimension_type(cls, dataset, dim):
@@ -426,7 +426,7 @@ class PandasInterface(Interface, PandasAPI):
                     index_sel = {}
             column_sel = {k: v for k, v in selection.items() if k not in index_sel}
             if column_sel:
-                selection_mask = cls.select_mask(dataset, column_sel)
+                selection_mask = cls.select_mask(dataset.clone(data=df), column_sel)
 
         indexed = cls.indexed(dataset, selection)
         if isinstance(selection_mask, pd.Series):
diff --git a/holoviews/core/util/__init__.py b/holoviews/core/util/__init__.py
index db5bcf6b0..755d4c80f 100644
--- a/holoviews/core/util/__init__.py
+++ b/holoviews/core/util/__init__.py
@@ -31,6 +31,7 @@ from .dependencies import (  # noqa: F401
     PANDAS_VERSION,
     PARAM_VERSION,
     VersionError,
+    _LazyModule,
 )
 from .types import (
     arraylike_types,
@@ -43,6 +44,14 @@ from .types import (
     timedelta_types,
 )
 
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    import pandas as pd
+    import polars as pl
+else:
+    pd = _LazyModule("pandas", bool_use_sys_modules=True)
+    pl = _LazyModule("polars", bool_use_sys_modules=True)
+
 # Python 2 builtins
 basestring = str
 long = int
@@ -800,10 +809,21 @@ label_sanitizer = sanitize_identifier_fn.instance()
 dimension_sanitizer = sanitize_identifier_fn.instance(capitalize=False)
 
 def isscalar(val):
-    """Value is scalar or None
+    """Value is scalar or nullable
 
     """
-    return val is None or np.isscalar(val) or isinstance(val, datetime_types)
+    return is_null_or_na_scalar(val) or np.isscalar(val) or isinstance(val, datetime_types)
+
+
+def is_null_or_na_scalar(val):
+    if hasattr(val, "__len__"):
+        return False
+    return bool(
+        val is None
+        or (pd and (val is pd.NA or val is pd.NaT))
+        or (pl and val is pl.Null)
+        or (np.isscalar(val) and np.isnan(val))
+    )
 
 
 def isnumeric(val):
@@ -1088,18 +1108,20 @@ def max_extents(extents, zrange=False):
         for lidx, uidx in inds:
             lower = [v for v in arr[lidx] if v is not None and not is_nan(v)]
             upper = [v for v in arr[uidx] if v is not None and not is_nan(v)]
-            if lower and isinstance(lower[0], datetime_types):
-                extents[lidx] = np.min(lower)
-            elif any(isinstance(l, str) for l in lower):
-                extents[lidx] = np.sort(lower)[0]
-            elif lower:
-                extents[lidx] = np.nanmin(lower)
-            if upper and isinstance(upper[0], datetime_types):
-                extents[uidx] = np.max(upper)
-            elif any(isinstance(u, str) for u in upper):
-                extents[uidx] = np.sort(upper)[-1]
-            elif upper:
-                extents[uidx] = np.nanmax(upper)
+            if lower:
+                if any(isinstance(l, str) for l in lower):
+                    extents[lidx] = sorted(lower, key=str)[0]
+                elif isinstance(lower[0], datetime_types):
+                    extents[lidx] = np.min(lower)
+                else:
+                    extents[lidx] = np.nanmin(lower)
+            if upper:
+                if any(isinstance(u, str) for u in upper):
+                    extents[uidx] = sorted(upper, key=str)[-1]
+                elif isinstance(upper[0], datetime_types):
+                    extents[uidx] = np.max(upper)
+                else:
+                    extents[uidx] = np.nanmax(upper)
     return tuple(extents)
 
 
diff --git a/holoviews/core/util/dependencies.py b/holoviews/core/util/dependencies.py
index 29350d7de..b4575e5ed 100644
--- a/holoviews/core/util/dependencies.py
+++ b/holoviews/core/util/dependencies.py
@@ -18,6 +18,8 @@ class VersionError(Exception):
 
 @lru_cache
 def _is_installed(module_name):
+    # So we don't accidentally import it
+    module_name, *_ = module_name.split(".")
     return find_spec(module_name) is not None
 
 
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index d1fe2d1b7..28c3e196d 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -1789,12 +1789,7 @@ class SpreadingOperation(LinkableOperation):
 
             for k, v in sel_data.items():
                 new_data[k].data = v
-
-            # TODO: Investigate why this does not work
-            # element = element.clone(data=new_data, kdims=element.vdims.copy(), vdims=element.vdims.copy())
-            element = element.clone()
-            element.data = new_data
-            return element
+            return element.clone(data=new_data)
 
         kwargs = {}
         if isinstance(element, RGB):
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index e76a1d4c7..b23b0a915 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -3237,7 +3237,10 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
                     else:
                         tool_type = type(tool)
                     if isinstance(tool, tools.HoverTool):
-                        tooltips = tuple(tool.tooltips) if tool.tooltips else ()
+                        if isinstance(tool.tooltips, bokeh.models.dom.Div):
+                            tooltips = tool.tooltips
+                        else:
+                            tooltips = tuple(tool.tooltips) if tool.tooltips else ()
                         if tooltips in hover_tools:
                             continue
                         else:
@@ -3267,7 +3270,9 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
             self.handles['hover'] = subplot.handles['hover']
         elif 'hover' in subplot.handles and 'hover_tools' in self.handles:
             hover = subplot.handles['hover']
-            if hover.tooltips and not isinstance(hover.tooltips, str):
+            if hover.tooltips and isinstance(hover.tooltips, bokeh.models.dom.Div):
+                tooltips = hover.tooltips
+            elif hover.tooltips and not isinstance(hover.tooltips, str):
                 tooltips = tuple((name, spec.replace('{%F %T}', '')) for name, spec in hover.tooltips)
             else:
                 tooltips = ()
diff --git a/pyproject.toml b/pyproject.toml
index f37e1bd15..75d288d9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,8 @@ name = "holoviews"
 dynamic = ["version"]
 description = "A high-level plotting API for the PyData ecosystem built on HoloViews."
 readme = "README.md"
-license = { text = "BSD" }
+license = "BSD-3-Clause"
+license-files = ["LICENSE.txt"]
 requires-python = ">=3.10"
 authors = [
     { name = "Jean-Luc Stevens", email = "developers@holoviz.org" },
@@ -15,7 +16,6 @@ authors = [
 ]
 maintainers = [{ name = "HoloViz developers", email = "developers@holoviz.org" }]
 classifiers = [
-    "License :: OSI Approved :: BSD License",
     "Development Status :: 5 - Production/Stable",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",


Test patch:
diff --git a/holoviews/tests/core/data/test_pandasinterface.py b/holoviews/tests/core/data/test_pandasinterface.py
index 1c4022922..aeb4c180d 100644
--- a/holoviews/tests/core/data/test_pandasinterface.py
+++ b/holoviews/tests/core/data/test_pandasinterface.py
@@ -4,6 +4,7 @@ import pytest
 
 from holoviews.core.data import Dataset
 from holoviews.core.data.interface import DataError
+from holoviews.core.data.pandas import PandasInterface
 from holoviews.core.dimension import Dimension
 from holoviews.core.spaces import HoloMap
 from holoviews.element import Distribution, Points, Scatter
@@ -351,6 +352,16 @@ class PandasInterfaceMultiIndex(HeterogeneousColumnTests, InterfaceTests):
         expected = self.df.loc[[2]]
         pd.testing.assert_frame_equal(selected.data, expected)
 
+    def test_select_index_and_column(self):
+        # See https://github.com/holoviz/holoviews/issues/6578
+        frame = pd.DataFrame({"number": [1, 1, 2, 2], "color": ["red", "blue", "red", "blue"]})
+        index = pd.MultiIndex.from_frame(frame, names=("number", "color"))
+        df = pd.DataFrame({"cat": list("abab"), "values": range(4)}, index=index)
+        ds = Dataset(df, kdims=["number"], vdims=["cat", "values"])
+        selected = ds.select(number=[1], cat=["a"])
+        expected = df[(df.index.get_level_values(0) == 1) & (df["cat"] == "a")]
+        pd.testing.assert_frame_equal(selected.data, expected)
+
     def test_sample(self):
         ds = Dataset(self.df, kdims=["number", "color"])
         sample = ds.interface.sample(ds, [1])
@@ -404,3 +415,10 @@ class PandasInterfaceMultiIndex(HeterogeneousColumnTests, InterfaceTests):
 
         plot = Scatter(self.df, kdims="number")
         np.testing.assert_equal(plot.dimension_values('number'), self.df.index.get_level_values('number'))
+
+
+def test_no_subclasse_interface_applies():
+    spd = pytest.importorskip("spatialpandas")
+    square = spd.geometry.Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
+    sdf = spd.GeoDataFrame({"geometry": spd.GeoSeries([square, square]), "name": ["A", "B"]})
+    assert PandasInterface.applies(sdf) is False
diff --git a/holoviews/tests/core/test_utils.py b/holoviews/tests/core/test_utils.py
index 47bc751d8..cf954f382 100644
--- a/holoviews/tests/core/test_utils.py
+++ b/holoviews/tests/core/test_utils.py
@@ -8,6 +8,7 @@ from itertools import product
 
 import numpy as np
 import pandas as pd
+import pytest
 
 from holoviews import Dimension, Element
 from holoviews.core.util import (
@@ -22,6 +23,7 @@ from holoviews.core.util import (
     find_range,
     get_path,
     is_nan,
+    is_null_or_na_scalar,
     isfinite,
     make_path_unique,
     max_range,
@@ -817,3 +819,30 @@ def test_is_nan():
     assert is_nan([1, 1]) == False
     assert is_nan([np.nan]) == True
     assert is_nan([np.nan, np.nan]) == False
+
+
+def test_is_null_or_na_scalar():
+    assert is_null_or_na_scalar(np.nan)
+    assert is_null_or_na_scalar(pd.NA)
+    assert is_null_or_na_scalar(pd.NaT)
+    assert is_null_or_na_scalar(None)
+    assert is_null_or_na_scalar(np.datetime64("NAT"))
+
+    assert not is_null_or_na_scalar(datetime.datetime.today())
+    assert not is_null_or_na_scalar(pd.Timestamp.now())
+    assert not is_null_or_na_scalar("AAAA")
+    assert not is_null_or_na_scalar(...)
+    assert not is_null_or_na_scalar([1, 2])
+    assert not is_null_or_na_scalar((1, 2))
+    assert not is_null_or_na_scalar({1, 2})
+    assert not is_null_or_na_scalar({"a": 1, "b": 2})
+    assert not is_null_or_na_scalar(slice(None))
+    assert not is_null_or_na_scalar(np.array([1, 2]))
+    assert not is_null_or_na_scalar(pd.DataFrame([1, 2]))
+
+
+def test_is_null_or_na_scalar_polars():
+    pl = pytest.importorskip("polars")
+    assert is_null_or_na_scalar(pl.Null)
+    assert not is_null_or_na_scalar(pl.DataFrame([1, 2]))
+    assert not is_null_or_na_scalar(pl.LazyFrame([1, 2]))
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index cf08006ec..e3425b880 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -31,6 +31,7 @@ from holoviews import (
     Spikes,
     Spread,
     TriMesh,
+    renderer,
 )
 from holoviews.element.comparison import ComparisonTestCase
 from holoviews.operation import apply_when
@@ -1390,6 +1391,12 @@ def test_selector_rasterize(point_plot, sel_fn):
     img_count = rasterize(point_plot, **inputs)
     np.testing.assert_array_equal(img["Count"], img_count["Count"])
 
+@pytest.mark.usefixtures("bokeh_backend")
+def test_selector_hover_in_overlay(point_plot):
+    inputs = dict(dynamic=False,  x_range=(-1, 1), y_range=(-1, 1), width=10, height=10)
+    overlay = rasterize(point_plot, selector=ds.first("val"), **inputs).opts(tools=["hover"]) * Points([])
+    renderer("bokeh").get_plot(overlay)
+
 @pytest.mark.parametrize("sel_fn", (ds.first, ds.last, ds.min, ds.max))
 def test_selector_datashade(point_plot, sel_fn):
     inputs = dict(dynamic=False,  x_range=(-1, 1), y_range=(-1, 1), width=10, height=10)
diff --git a/holoviews/tests/plotting/bokeh/test_overlayplot.py b/holoviews/tests/plotting/bokeh/test_overlayplot.py
index 066ac9c13..838d0dc1a 100644
--- a/holoviews/tests/plotting/bokeh/test_overlayplot.py
+++ b/holoviews/tests/plotting/bokeh/test_overlayplot.py
@@ -1,5 +1,7 @@
 import numpy as np
+import pandas as pd
 import panel as pn
+import pytest
 from bokeh.models import FactorRange, FixedTicker, HoverTool, Range1d, Span
 
 from holoviews.core import DynamicMap, HoloMap, NdOverlay, Overlay
@@ -402,6 +404,25 @@ class TestOverlayPlot(LoggingComparisonTestCase, TestBokehPlot):
             assert y_range.end == ydata.max()
 
 
+@pytest.mark.parametrize('order', [("str", "date"), ("date", "str")])
+def test_ndoverlay_categorical_y_ranges(order):
+    df = pd.DataFrame(
+        {
+            "str": ["apple", "banana", "cherry", "date", "elderberry"],
+            "date": pd.to_datetime(
+                ["2023-01-01", "2023-02-14", "2023-03-21", "2023-04-30", "2023-05-15"]
+            ),
+        }
+    )
+    overlay = NdOverlay(
+        {col: Scatter(df, kdims="index", vdims=col) for col in order}
+    )
+    plot = bokeh_renderer.get_plot(overlay)
+    output = plot.handles["y_range"].factors
+    expected = sorted(map(str, df.values.ravel()))
+    assert output == expected
+
+
 class TestLegends(TestBokehPlot):
 
     def test_overlay_legend(self):