Repository: holoviz/holoviews
Pull request #6362 (2024-08-17 13:23:44)

enh: Add `is_null_or_na_scalar`
The main problem is `pd.NA` weird behavior gives a problem when Bokeh tries to match it in `Propery.matches`, see https://github.com/bokeh/bokeh/issues/14476

I have repurposed the PR to add `is_null_or_na_scalar`

---
OLD:

Fixes part of the problem observed in https://github.com/holoviz/holoviews/issues/6308#issuecomment-2294849025


``` python
import geopandas as gpd
import holoviews as hv
import pandas as pd
import spatialpandas as spd
from holoviews.operation.datashader import datashade, inspect_polygons
from shapely.geometry import MultiPolygon, Polygon

hv.extension("bokeh")

square1 = Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
square2 = Polygon([(2, 0), (2, 1), (3, 1), (3, 0)])
gdf = gpd.GeoDataFrame({"geometry": [square1, square2], "name": [pd.NA, "B"]})
sdf = spd.GeoDataFrame(gdf)

polys = hv.Polygons(sdf)
shaded = datashade(polys)
hover = inspect_polygons(shaded).opts(fill_color="red", tools=["hover"])
(shaded * hover)
```

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6362?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
Attention: Patch coverage is `66.66667%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 88.49%. Comparing base [(`59d81fa`)](https://app.codecov.io/gh/holoviz/holoviews/commit/59d81faa44bbfe9f9c4d3752f585f23e5af6d59e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`a9f579a`)](https://app.codecov.io/gh/holoviz/holoviews/commit/a9f579ab02a65174df2aa258c7195c4ea4e41849?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).

| [Files](https://app.codecov.io/gh/holoviz/holoviews/pull/6362?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Patch % | Lines |
|---|---|---|
| [holoviews/core/data/spatialpandas.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6362?src=pr&el=tree&filepath=holoviews%2Fcore%2Fdata%2Fspatialpandas.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2NvcmUvZGF0YS9zcGF0aWFscGFuZGFzLnB5) | 66.66% | [1 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6362?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |
| [holoviews/core/util.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6362?src=pr&el=tree&filepath=holoviews%2Fcore%2Futil.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2NvcmUvdXRpbC5weQ==) | 66.66% | [1 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6362?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6362      +/-   ##
==========================================
- Coverage   88.49%   88.49%   -0.01%     
==========================================
  Files         323      323              
  Lines       68179    68183       +4     
==========================================
  Hits        60337    60337              
- Misses       7842     7846       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6362?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).


Patch:
diff --git a/holoviews/core/util/__init__.py b/holoviews/core/util/__init__.py
index db5bcf6b0..8e0c7e6d0 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):
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/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/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]))