Repository: holoviz/holoviews
Pull request #6583 (2025-05-22 12:17:43)

docs: Add numpydoc extension to sphinx
This PR adds the `numpydoc` extension to the sphinx build process.

hoxbro: Please give some before/after images.  

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/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 = ()


Test patch:
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):