Repository: pydata/xarray

Issue:
# `NDPointIndex` should fail early and throw nice error if scipy not installed

### What happened?

[NDPointIndex](https://docs.xarray.dev/en/latest/generated/xarray.indexes.NDPointIndex.html#xarray.indexes.NDPointIndex) has a hard dependency on scipy but when you try to use it with a default xarray install with no extras 

you get this error:

```
➜  nd-scipy git:(main) ✗ uv run nd_scipy.py
Traceback (most recent call last):
  File "/Users/ian/Documents/dev/testing/nd-scipy/nd_scipy.py", line 19, in <module>
    ds_index = ds.set_xindex(("xx", "yy"), xr.indexes.NDPointIndex)
  File "/Users/ian/.cache/uv/environments-v2/nd-scipy-363973e27d29e760/lib/python3.14/site-packages/xarray/core/dataset.py", line 5019, in set_xindex
    index = index_cls.from_variables(coord_vars, options=options)
  File "/Users/ian/.cache/uv/environments-v2/nd-scipy-363973e27d29e760/lib/python3.14/site-packages/xarray/indexes/nd_point_index.py", line 276, in from_variables
    tree_adapter_cls(points, options=opts),
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ian/.cache/uv/environments-v2/nd-scipy-363973e27d29e760/lib/python3.14/site-packages/xarray/indexes/nd_point_index.py", line 78, in __init__
    from scipy.spatial import KDTree
ModuleNotFoundError: No module named 'scipy'
```

which could be confusing as a user.

### What did you expect to happen?

- An error raised on import of the Index rather only once I try to create one
- More informative error message saying something like "to use NDPointIndex please install scipy"

Similar to how there is a nice message if you are missing a backend:

> ValueError: unrecognized engine 'zarr' must be one of your download engines: ['store']. To install additional dependencies, see:
https://docs.xarray.dev/en/stable/user-guide/io.html
https://docs.xarray.dev/en/stable/getting-started-guide/installing.html

### Minimal Complete Verifiable Example

```Python
# /// script
# requires-python = ">=3.14"
# dependencies = [
#     "xarray",
# ]
# ///


import numpy as np
import xarray as xr
from xarray.indexes import NDPointIndex

shape = (5, 10)
xx = xr.DataArray(np.random.uniform(0, 10, size=shape), dims=("y", "x"))
yy = xr.DataArray(np.random.uniform(0, 5, size=shape), dims=("y", "x"))
data = (xx - 5) ** 2 + (yy - 2.5) ** 2

ds = xr.Dataset(data_vars={"data": data}, coords={"xx": xx, "yy": yy})
ds_index = ds.set_xindex(("xx", "yy"), NDPointIndex)
```

### Steps to reproduce

`uv run`

### MVCE confirmation

- [x] Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray.
- [x] Complete example — the example is self-contained, including all data and the text of any traceback.
- [x] Verifiable example — the example copy & pastes into an IPython prompt or [Binder notebook](https://mybinder.org/v2/gh/pydata/xarray/main?urlpath=lab/tree/doc/examples/blank_template.ipynb), returning the result.
- [x] New issue — a search of GitHub Issues suggests this is not a duplicate.
- [x] Recent environment — the issue occurs with the latest version of xarray and its dependencies.

### Relevant log output

```Python

```

### Anything else we need to know?

_No response_

### Environment

<details>



</details>


Implementation patch:
diff --git a/xarray/indexes/nd_point_index.py b/xarray/indexes/nd_point_index.py
index 8d2637a5a3d..1469d59e4fa 100644
--- a/xarray/indexes/nd_point_index.py
+++ b/xarray/indexes/nd_point_index.py
@@ -75,7 +75,13 @@ class ScipyKDTreeAdapter(TreeAdapter):
     _kdtree: KDTree
 
     def __init__(self, points: np.ndarray, options: Mapping[str, Any]):
-        from scipy.spatial import KDTree
+        try:
+            from scipy.spatial import KDTree
+        except ImportError as err:
+            raise ImportError(
+                "`NDPointIndex` requires `scipy` when used with `ScipyKDTreeAdapter`. "
+                "Please ensure that `scipy` is installed and importable."
+            ) from err
 
         self._kdtree = KDTree(points, **options)
 


Test patch:
diff --git a/xarray/tests/test_nd_point_index.py b/xarray/tests/test_nd_point_index.py
index bef14bf750d..7c2cb8302c6 100644
--- a/xarray/tests/test_nd_point_index.py
+++ b/xarray/tests/test_nd_point_index.py
@@ -3,11 +3,19 @@
 
 import xarray as xr
 from xarray.indexes import NDPointIndex
-from xarray.tests import assert_identical
+from xarray.indexes.nd_point_index import ScipyKDTreeAdapter
+from xarray.tests import assert_identical, has_scipy, requires_scipy
 
-pytest.importorskip("scipy")
 
+@pytest.mark.skipif(has_scipy, reason="requires scipy to be missing")
+def test_scipy_kdtree_adapter_missing_scipy():
+    points = np.random.rand(4, 2)
 
+    with pytest.raises(ImportError, match=r"scipy"):
+        ScipyKDTreeAdapter(points, options={})
+
+
+@requires_scipy
 def test_tree_index_init() -> None:
     from xarray.indexes.nd_point_index import ScipyKDTreeAdapter
 
@@ -26,6 +34,7 @@ def test_tree_index_init() -> None:
     assert ds_indexed1.xindexes["xx"].equals(ds_indexed2.xindexes["yy"])
 
 
+@requires_scipy
 def test_tree_index_init_errors() -> None:
     xx, yy = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds = xr.Dataset(coords={"xx": (("y", "x"), xx), "yy": (("y", "x"), yy)})
@@ -39,6 +48,7 @@ def test_tree_index_init_errors() -> None:
         ds2.set_xindex(("xx", "yy"), NDPointIndex)
 
 
+@requires_scipy
 def test_tree_index_sel() -> None:
     xx, yy = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds = xr.Dataset(coords={"xx": (("y", "x"), xx), "yy": (("y", "x"), yy)}).set_xindex(
@@ -112,6 +122,7 @@ def test_tree_index_sel() -> None:
     assert_identical(actual, expected)
 
 
+@requires_scipy
 def test_tree_index_sel_errors() -> None:
     xx, yy = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds = xr.Dataset(coords={"xx": (("y", "x"), xx), "yy": (("y", "x"), yy)}).set_xindex(
@@ -137,6 +148,7 @@ def test_tree_index_sel_errors() -> None:
         )
 
 
+@requires_scipy
 def test_tree_index_equals() -> None:
     xx1, yy1 = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds1 = xr.Dataset(