Repository: pydata/xarray

Issue:
Unclear error message when combine_by_coords doesn't find an index
### What is your issue?

The error you get from inside `xr.combine_by_coords` when a 1D dimension coordinate is not backed by an index uses outdated verbiage. That's because it predates the indexes refactor, and this fail case wasn't anticipated at the time of writing.

The reproducer below uses the [`VirtualiZarr` package](https://github.com/zarr-developers/VirtualiZarr), but only as a shortcut to generate a dataset that has 1D coordinates not backed by indexes. You could construct a pure-xarray reproducer.

```python
In [1]: from virtualizarr import open_virtual_dataset

In [2]: import xarray as xr

In [3]: ds1 = open_virtual_dataset('air1.nc', indexes={})

In [4]: ds2 = open_virtual_dataset('air2.nc', indexes={})

In [5]: xr.combine_by_coords([ds1, ds2], coords='minimal', compat='override')
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[5], line 1
----> 1 xr.combine_by_coords([ds1, ds2], coords='minimal', compat='override')

File ~/miniconda3/envs/numpy2.0_released/lib/python3.11/site-packages/xarray/core/combine.py:958, in combine_by_coords(data_objects, compat, data_vars, coords, fill_value, join, combine_attrs)
    954     grouped_by_vars = itertools.groupby(sorted_datasets, key=vars_as_keys)
    956     # Perform the multidimensional combine on each group of data variables
    957     # before merging back together
--> 958     concatenated_grouped_by_data_vars = tuple(
    959         _combine_single_variable_hypercube(
    960             tuple(datasets_with_same_vars),
    961             fill_value=fill_value,
    962             data_vars=data_vars,
    963             coords=coords,
    964             compat=compat,
    965             join=join,
    966             combine_attrs=combine_attrs,
    967         )
    968         for vars, datasets_with_same_vars in grouped_by_vars
    969     )
    971 return merge(
    972     concatenated_grouped_by_data_vars,
    973     compat=compat,
   (...)
    976     combine_attrs=combine_attrs,
    977 )

File ~/miniconda3/envs/numpy2.0_released/lib/python3.11/site-packages/xarray/core/combine.py:959, in <genexpr>(.0)
    954     grouped_by_vars = itertools.groupby(sorted_datasets, key=vars_as_keys)
    956     # Perform the multidimensional combine on each group of data variables
    957     # before merging back together
    958     concatenated_grouped_by_data_vars = tuple(
--> 959         _combine_single_variable_hypercube(
    960             tuple(datasets_with_same_vars),
    961             fill_value=fill_value,
    962             data_vars=data_vars,
    963             coords=coords,
    964             compat=compat,
    965             join=join,
    966             combine_attrs=combine_attrs,
    967         )
    968         for vars, datasets_with_same_vars in grouped_by_vars
    969     )
    971 return merge(
    972     concatenated_grouped_by_data_vars,
    973     compat=compat,
   (...)
    976     combine_attrs=combine_attrs,
    977 )

File ~/miniconda3/envs/numpy2.0_released/lib/python3.11/site-packages/xarray/core/combine.py:619, in _combine_single_variable_hypercube(datasets, fill_value, data_vars, coords, compat, join, combine_attrs)
    613 if len(datasets) == 0:
    614     raise ValueError(
    615         "At least one Dataset is required to resolve variable names "
    616         "for combined hypercube."
    617     )
--> 619 combined_ids, concat_dims = _infer_concat_order_from_coords(list(datasets))
    621 if fill_value is None:
    622     # check that datasets form complete hypercube
    623     _check_shape_tile_ids(combined_ids)

File ~/miniconda3/envs/numpy2.0_released/lib/python3.11/site-packages/xarray/core/combine.py:92, in _infer_concat_order_from_coords(datasets)
     90 indexes = [ds._indexes.get(dim) for ds in datasets]
     91 if any(index is None for index in indexes):
---> 92     raise ValueError(
     93         "Every dimension needs a coordinate for "
     94         "inferring concatenation order"
     95     )
     97 # TODO (benbovy, flexible indexes): support flexible indexes?
     98 indexes = [index.to_pandas_index() for index in indexes]

ValueError: Every dimension needs a coordinate for inferring concatenation order
```
In this reproducer the dimension `time` has a coordinate, it just doesn't have an index backing that coordinate. The error message also doesn't say which dimension is the problem.

This error message should say something more like

```python
"ValueError: Every dimension requires a corresponding 1D coordinate and index for inferring concatenation order but the coordinate 'time' has no corresponding index"
```

One could even argue that the name `combine_by_coords` should really be `combine_using_indexes` ...

Discussion:
max-sixty: Looks good! Can we fix the test which matches on the previous error message?

Patch:
diff --git a/doc/internals/chunked-arrays.rst b/doc/internals/chunked-arrays.rst
index ba7ce72c..eb0a8b19 100644
--- a/doc/internals/chunked-arrays.rst
+++ b/doc/internals/chunked-arrays.rst
@@ -91,7 +91,8 @@ Once the chunkmanager subclass has been registered, xarray objects wrapping the
 The latter two methods ultimately call the chunkmanager's implementation of ``.from_array``, to which they pass the ``from_array_kwargs`` dict.
 The ``chunked_array_type`` kwarg selects which registered chunkmanager subclass to dispatch to. It defaults to ``'dask'``
 if Dask is installed, otherwise it defaults to whichever chunkmanager is registered if only one is registered.
-If multiple chunkmanagers are registered it will raise an error by default.
+If multiple chunkmanagers are registered, the ``chunk_manager`` configuration option (which can be set using :py:func:`set_options`)
+will be used to determine which chunkmanager to use, defaulting to ``'dask'``.
 
 Parallel processing without chunks
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/doc/user-guide/reshaping.rst b/doc/user-guide/reshaping.rst
index 14b34354..e3ff836d 100644
--- a/doc/user-guide/reshaping.rst
+++ b/doc/user-guide/reshaping.rst
@@ -274,7 +274,7 @@ Sort
 ----
 
 One may sort a DataArray/Dataset via :py:meth:`~xarray.DataArray.sortby` and
-:py:meth:`~xarray.Dataset.sortby`.  The input can be an individual or list of
+:py:meth:`~xarray.Dataset.sortby`. The input can be an individual or list of
 1D ``DataArray`` objects:
 
 .. ipython:: python
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 67e58e52..c8f3a40e 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -22,7 +22,8 @@ v2024.07.1 (unreleased)
 
 New Features
 ~~~~~~~~~~~~
-
+- Make chunk manager an option in ``set_options`` (:pull:`9362`).
+  By `Tom White <https://github.com/tomwhite>`_.
 
 Breaking changes
 ~~~~~~~~~~~~~~~~
diff --git a/xarray/core/combine.py b/xarray/core/combine.py
index 6f652cb6..4b4a07dd 100644
--- a/xarray/core/combine.py
+++ b/xarray/core/combine.py
@@ -89,10 +89,12 @@ def _infer_concat_order_from_coords(datasets):
             # Need to read coordinate values to do ordering
             indexes = [ds._indexes.get(dim) for ds in datasets]
             if any(index is None for index in indexes):
-                raise ValueError(
-                    "Every dimension needs a coordinate for "
-                    "inferring concatenation order"
+                error_msg = (
+                    f"Every dimension requires a corresponding 1D coordinate "
+                    f"and index for inferring concatenation order but the "
+                    f"coordinate '{dim}' has no corresponding index"
                 )
+                raise ValueError(error_msg)
 
             # TODO (benbovy, flexible indexes): support flexible indexes?
             indexes = [index.to_pandas_index() for index in indexes]
diff --git a/xarray/core/options.py b/xarray/core/options.py
index f5614104..f31413a2 100644
--- a/xarray/core/options.py
+++ b/xarray/core/options.py
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
 
     Options = Literal[
         "arithmetic_join",
+        "chunk_manager",
         "cmap_divergent",
         "cmap_sequential",
         "display_max_rows",
@@ -36,6 +37,7 @@ if TYPE_CHECKING:
     class T_Options(TypedDict):
         arithmetic_broadcast: bool
         arithmetic_join: Literal["inner", "outer", "left", "right", "exact"]
+        chunk_manager: str
         cmap_divergent: str | Colormap
         cmap_sequential: str | Colormap
         display_max_rows: int
@@ -62,6 +64,7 @@ if TYPE_CHECKING:
 OPTIONS: T_Options = {
     "arithmetic_broadcast": True,
     "arithmetic_join": "inner",
+    "chunk_manager": "dask",
     "cmap_divergent": "RdBu_r",
     "cmap_sequential": "viridis",
     "display_max_rows": 12,
@@ -172,7 +175,9 @@ class set_options:
         - "override": if indexes are of same size, rewrite indexes to be
           those of the first object with that dimension. Indexes for the same
           dimension must have the same size in all objects.
-
+    chunk_manager : str, default: "dask"
+        Chunk manager to use for chunked array computations when multiple
+        options are installed.
     cmap_divergent : str or matplotlib.colors.Colormap, default: "RdBu_r"
         Colormap to use for divergent data plots. If string, must be
         matplotlib built-in colormap. Can also be a Colormap object
diff --git a/xarray/namedarray/parallelcompat.py b/xarray/namedarray/parallelcompat.py
index 3394aca9..b90e0f99 100644
--- a/xarray/namedarray/parallelcompat.py
+++ b/xarray/namedarray/parallelcompat.py
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar
 
 import numpy as np
 
+from xarray.core.options import OPTIONS
 from xarray.core.utils import emit_user_level_warning
 from xarray.namedarray.pycompat import is_chunked_array
 
@@ -101,8 +102,8 @@ def guess_chunkmanager(
             # use the only option available
             manager = next(iter(chunkmanagers.keys()))
         else:
-            # default to trying to use dask
-            manager = "dask"
+            # use the one in options (default dask)
+            manager = OPTIONS["chunk_manager"]
 
     if isinstance(manager, str):
         if manager not in chunkmanagers:


Test patch:
diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py
index 507886ca..be90f90a 100644
--- a/xarray/tests/test_backends.py
+++ b/xarray/tests/test_backends.py
@@ -2096,6 +2096,11 @@ class TestNetCDF4ViaDaskData(TestNetCDF4Data):
             assert actual["x"].encoding["chunksizes"] == (50, 100)
             assert actual["y"].encoding["chunksizes"] == (100, 50)
 
+    # Flaky test. Very open to contributions on fixing this
+    @pytest.mark.flaky
+    def test_roundtrip_coordinates(self) -> None:
+        super().test_roundtrip_coordinates()
+
 
 @requires_zarr
 class ZarrBase(CFEncodedBase):
diff --git a/xarray/tests/test_combine.py b/xarray/tests/test_combine.py
index aad7103c..1c48dca8 100644
--- a/xarray/tests/test_combine.py
+++ b/xarray/tests/test_combine.py
@@ -728,7 +728,10 @@ class TestCombineDatasetsbyCoords:
             combine_by_coords(objs)
 
         objs = [Dataset({"x": [0], "y": [0]}), Dataset({"x": [0]})]
-        with pytest.raises(ValueError, match=r"Every dimension needs a coordinate"):
+        with pytest.raises(
+            ValueError,
+            match=r"Every dimension requires a corresponding 1D coordinate and index",
+        ):
             combine_by_coords(objs)
 
     def test_empty_input(self):
diff --git a/xarray/tests/test_parallelcompat.py b/xarray/tests/test_parallelcompat.py
index dbe40be7..67c68aac 100644
--- a/xarray/tests/test_parallelcompat.py
+++ b/xarray/tests/test_parallelcompat.py
@@ -6,6 +6,7 @@ from typing import Any
 import numpy as np
 import pytest
 
+from xarray import set_options
 from xarray.core.types import T_Chunks, T_DuckArray, T_NormalizedChunks
 from xarray.namedarray._typing import _Chunks
 from xarray.namedarray.daskmanager import DaskManager
@@ -152,6 +153,11 @@ class TestGetChunkManager:
         chunkmanager = guess_chunkmanager("dummy")
         assert isinstance(chunkmanager, DummyChunkManager)
 
+    def test_get_chunkmanger_via_set_options(self, register_dummy_chunkmanager) -> None:
+        with set_options(chunk_manager="dummy"):
+            chunkmanager = guess_chunkmanager(None)
+            assert isinstance(chunkmanager, DummyChunkManager)
+
     def test_fail_on_nonexistent_chunkmanager(self) -> None:
         with pytest.raises(ValueError, match="unrecognized chunk manager foo"):
             guess_chunkmanager("foo")