Repository: pydata/xarray

Issue:
DataArray types must be strings
### What happened?

While trying to understand the way dimensions are typed in `namedarray` I was confused by the requirement that dimension names must be strings:

```python
>>> da = xr.DataArray(data=[1,2,3], dims=[7])
TypeError: dimension 7 is not a string
```

However, the type hints suggest that dimension names can more generally be `Hashable`. According to @headtr1ck's [post](https://github.com/pydata/xarray/pull/8294#issuecomment-1852714645), the desired behavior is that `Hashable` dimension names should be allowed, so this `TypeError` may be considered a bug.

### What did you expect to happen?

_No response_

### Minimal Complete Verifiable Example

_No response_

### 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

_No response_

### Anything else we need to know?

_No response_

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.11.6 | packaged by conda-forge | (main, Oct  3 2023, 10:40:35) [GCC 12.3.0]
python-bits: 64
OS: Linux
OS-release: 5.15.0-89-generic
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: C.UTF-8
LANG: C.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.14.2
libnetcdf: 4.9.2

xarray: 2023.11.0
pandas: 1.5.3
numpy: 1.26.2
scipy: 1.11.4
netCDF4: 1.6.5
pydap: None
h5netcdf: 1.3.0
h5py: 3.10.0
Nio: None
zarr: 2.16.1
cftime: 1.6.3
nc_time_axis: None
iris: None
bottleneck: None
dask: 2023.11.0
distributed: 2023.11.0
matplotlib: 3.8.2
cartopy: 0.22.0
seaborn: 0.13.0
numbagg: None
fsspec: 2023.10.0
cupy: None
pint: None
sparse: 0.14.0
flox: 0.8.3
numpy_groupies: 0.10.2
setuptools: 68.2.2
pip: 23.3.1
conda: 23.10.0
pytest: 7.4.3
mypy: 1.7.1
IPython: 8.18.0
sphinx: None
[/opt/conda/lib/python3.11/site-packages/_distutils_hack/__init__.py:33](https://vscode-remote+dev-002dcontainer-002b7b22686f737450617468223a222f686f6d652f6d617265732f7265706f732f6174747269627574696f6e222c226c6f63616c446f636b6572223a66616c73652c22636f6e66696746696c65223a7b22246d6964223a312c22667350617468223a222f686f6d652f6d617265732f7265706f732f6174747269627574696f6e2f2e646576636f6e7461696e65722f646576636f6e7461696e65722e6a736f6e222c2265787465726e616c223a2266696c653a2f2f2f686f6d652f6d617265732f7265706f732f6174747269627574696f6e2f2e646576636f6e7461696e65722f646576636f6e7461696e65722e6a736f6e222c2270617468223a222f686f6d652f6d617265732f7265706f732f6174747269627574696f6e2f2e646576636f6e7461696e65722f646576636f6e7461696e65722e6a736f6e222c22736368656d65223a2266696c65227d7d.vscode-resource.vscode-cdn.net/opt/conda/lib/python3.11/site-packages/_distutils_hack/__init__.py:33): UserWarning: Setuptools is replacing distutils.
  warnings.warn("Setuptools is replacing distutils.")

</details>


Discussion:
TomNicholas: > Probably we should add a whole bunch of tests for this. For now only testing the constructor.

I think I've said this before but eventually I would like to see us use property-based testing and test using a strategy that generates arbitrary strings / hashables. We could build off of https://github.com/pydata/xarray/pull/6903 and generalise the `names` strategy that was introduced in https://github.com/pydata/xarray/pull/8404, for example.
max-sixty: > Probably we should add a whole bunch of tests for this.

We could add them onto a canonical test object like https://github.com/pydata/xarray/pull/8533 (maybe there's a param for "object with lots of quirks"), and then if we can spread that object throughout the test suite, we get every method tested on the full range of quirks

(I see @TomNicholas just replied with a suggestion to use hypothesis — agree — I think that would provide more depth (more quirks!), though less wide coverage, given we're unlikely to write every test using hypothesis)
headtr1ck: Should we explicitly disallow using `None` as dimension/variable names?
It will inadvertently lead to problems later on.

Patch:
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index ba8856e1..f9d30817 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -63,6 +63,8 @@ Deprecations
 Bug fixes
 ~~~~~~~~~
 
+- Support non-string hashable dimensions in :py:class:`xarray.DataArray` (:issue:`8546`, :pull:`8559`).
+  By `Michael Niklas <https://github.com/headtr1ck>`_.
 - Reverse index output of bottleneck's rolling move_argmax/move_argmin functions (:issue:`8541`, :pull:`8552`).
   By `Kai Mühlbauer <https://github.com/kmuehlbauer>`_.
 - Vendor `SerializableLock` from dask and use as default lock for netcdf4 backends (:issue:`8442`, :pull:`8571`).
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index e8d6f821..d2ea0f8a 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -11,6 +11,8 @@ from typing import (
     Generic,
     Literal,
     NoReturn,
+    TypeVar,
+    Union,
     overload,
 )
 
@@ -61,6 +63,7 @@ from xarray.core.utils import (
     ReprObject,
     _default,
     either_dict_or_kwargs,
+    hashable,
 )
 from xarray.core.variable import (
     IndexVariable,
@@ -73,23 +76,11 @@ from xarray.plot.utils import _get_units_from_attrs
 from xarray.util.deprecation_helpers import _deprecate_positional_args, deprecate_dims
 
 if TYPE_CHECKING:
-    from typing import TypeVar, Union
-
+    from dask.dataframe import DataFrame as DaskDataFrame
+    from dask.delayed import Delayed
+    from iris.cube import Cube as iris_Cube
     from numpy.typing import ArrayLike
 
-    try:
-        from dask.dataframe import DataFrame as DaskDataFrame
-    except ImportError:
-        DaskDataFrame = None
-    try:
-        from dask.delayed import Delayed
-    except ImportError:
-        Delayed = None  # type: ignore[misc,assignment]
-    try:
-        from iris.cube import Cube as iris_Cube
-    except ImportError:
-        iris_Cube = None
-
     from xarray.backends import ZarrStore
     from xarray.backends.api import T_NetcdfEngine, T_NetcdfTypes
     from xarray.core.groupby import DataArrayGroupBy
@@ -140,7 +131,9 @@ def _check_coords_dims(shape, coords, dim):
 
 
 def _infer_coords_and_dims(
-    shape, coords, dims
+    shape: tuple[int, ...],
+    coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None,
+    dims: str | Iterable[Hashable] | None,
 ) -> tuple[Mapping[Hashable, Any], tuple[Hashable, ...]]:
     """All the logic for creating a new DataArray"""
 
@@ -157,8 +150,7 @@ def _infer_coords_and_dims(
 
     if isinstance(dims, str):
         dims = (dims,)
-
-    if dims is None:
+    elif dims is None:
         dims = [f"dim_{n}" for n in range(len(shape))]
         if coords is not None and len(coords) == len(shape):
             # try to infer dimensions from coords
@@ -168,16 +160,15 @@ def _infer_coords_and_dims(
                 for n, (dim, coord) in enumerate(zip(dims, coords)):
                     coord = as_variable(coord, name=dims[n]).to_index_variable()
                     dims[n] = coord.name
-        dims = tuple(dims)
-    elif len(dims) != len(shape):
+    dims_tuple = tuple(dims)
+    if len(dims_tuple) != len(shape):
         raise ValueError(
             "different number of dimensions on data "
-            f"and dims: {len(shape)} vs {len(dims)}"
+            f"and dims: {len(shape)} vs {len(dims_tuple)}"
         )
-    else:
-        for d in dims:
-            if not isinstance(d, str):
-                raise TypeError(f"dimension {d} is not a string")
+    for d in dims_tuple:
+        if not hashable(d):
+            raise TypeError(f"Dimension {d} is not hashable")
 
     new_coords: Mapping[Hashable, Any]
 
@@ -189,17 +180,21 @@ def _infer_coords_and_dims(
             for k, v in coords.items():
                 new_coords[k] = as_variable(v, name=k)
         elif coords is not None:
-            for dim, coord in zip(dims, coords):
+            for dim, coord in zip(dims_tuple, coords):
                 var = as_variable(coord, name=dim)
                 var.dims = (dim,)
                 new_coords[dim] = var.to_index_variable()
 
-    _check_coords_dims(shape, new_coords, dims)
+    _check_coords_dims(shape, new_coords, dims_tuple)
 
-    return new_coords, dims
+    return new_coords, dims_tuple
 
 
-def _check_data_shape(data, coords, dims):
+def _check_data_shape(
+    data: Any,
+    coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None,
+    dims: str | Iterable[Hashable] | None,
+) -> Any:
     if data is dtypes.NA:
         data = np.nan
     if coords is not None and utils.is_scalar(data, include_0d=False):
@@ -405,10 +400,8 @@ class DataArray(
     def __init__(
         self,
         data: Any = dtypes.NA,
-        coords: Sequence[Sequence[Any] | pd.Index | DataArray]
-        | Mapping[Any, Any]
-        | None = None,
-        dims: Hashable | Sequence[Hashable] | None = None,
+        coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None = None,
+        dims: str | Iterable[Hashable] | None = None,
         name: Hashable | None = None,
         attrs: Mapping | None = None,
         # internal parameters
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index c1908391..c83a56bb 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -130,6 +130,8 @@ from xarray.plot.accessor import DatasetPlotAccessor
 from xarray.util.deprecation_helpers import _deprecate_positional_args
 
 if TYPE_CHECKING:
+    from dask.dataframe import DataFrame as DaskDataFrame
+    from dask.delayed import Delayed
     from numpy.typing import ArrayLike
 
     from xarray.backends import AbstractDataStore, ZarrStore
@@ -164,15 +166,6 @@ if TYPE_CHECKING:
     )
     from xarray.core.weighted import DatasetWeighted
 
-    try:
-        from dask.delayed import Delayed
-    except ImportError:
-        Delayed = None  # type: ignore[misc,assignment]
-    try:
-        from dask.dataframe import DataFrame as DaskDataFrame
-    except ImportError:
-        DaskDataFrame = None
-
 
 # list of attributes of pd.DatetimeIndex that are ndarrays of time info
 _DATETIMEINDEX_COMPONENTS = [


Test patch:
diff --git a/xarray/tests/__init__.py b/xarray/tests/__init__.py
index 9d65c3ef..207caba4 100644
--- a/xarray/tests/__init__.py
+++ b/xarray/tests/__init__.py
@@ -59,7 +59,11 @@ def _importorskip(
                 raise ImportError("Minimum version not satisfied")
     except ImportError:
         has = False
-    func = pytest.mark.skipif(not has, reason=f"requires {modname}")
+
+    reason = f"requires {modname}"
+    if minversion is not None:
+        reason += f">={minversion}"
+    func = pytest.mark.skipif(not has, reason=reason)
     return has, func
 
 
@@ -122,10 +126,7 @@ requires_pandas_version_two = pytest.mark.skipif(
     not has_pandas_version_two, reason="requires pandas 2.0.0"
 )
 has_numpy_array_api, requires_numpy_array_api = _importorskip("numpy", "1.26.0")
-has_h5netcdf_ros3 = _importorskip("h5netcdf", "1.3.0")
-requires_h5netcdf_ros3 = pytest.mark.skipif(
-    not has_h5netcdf_ros3[0], reason="requires h5netcdf 1.3.0"
-)
+has_h5netcdf_ros3, requires_h5netcdf_ros3 = _importorskip("h5netcdf", "1.3.0")
 
 has_netCDF4_1_6_2_or_above, requires_netCDF4_1_6_2_or_above = _importorskip(
     "netCDF4", "1.6.2"
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index 6dc85fc5..ab1fc316 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -401,8 +401,8 @@ class TestDataArray:
         with pytest.raises(ValueError, match=r"not a subset of the .* dim"):
             DataArray(data, {"x": [0, 1, 2]})
 
-        with pytest.raises(TypeError, match=r"is not a string"):
-            DataArray(data, dims=["x", None])
+        with pytest.raises(TypeError, match=r"is not hashable"):
+            DataArray(data, dims=["x", []])  # type: ignore[list-item]
 
         with pytest.raises(ValueError, match=r"conflicting sizes for dim"):
             DataArray([1, 2, 3], coords=[("x", [0, 1])])
diff --git a/xarray/tests/test_hashable.py b/xarray/tests/test_hashable.py
new file mode 100644
index 00000000..9f92c604
--- /dev/null
+++ b/xarray/tests/test_hashable.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import TYPE_CHECKING, Union
+
+import pytest
+
+from xarray import DataArray, Dataset, Variable
+
+if TYPE_CHECKING:
+    from xarray.core.types import TypeAlias
+
+    DimT: TypeAlias = Union[int, tuple, "DEnum", "CustomHashable"]
+
+
+class DEnum(Enum):
+    dim = "dim"
+
+
+class CustomHashable:
+    def __init__(self, a: int) -> None:
+        self.a = a
+
+    def __hash__(self) -> int:
+        return self.a
+
+
+parametrize_dim = pytest.mark.parametrize(
+    "dim",
+    [
+        pytest.param(5, id="int"),
+        pytest.param(("a", "b"), id="tuple"),
+        pytest.param(DEnum.dim, id="enum"),
+        pytest.param(CustomHashable(3), id="HashableObject"),
+    ],
+)
+
+
+@parametrize_dim
+def test_hashable_dims(dim: DimT) -> None:
+    v = Variable([dim], [1, 2, 3])
+    da = DataArray([1, 2, 3], dims=[dim])
+    Dataset({"a": ([dim], [1, 2, 3])})
+
+    # alternative constructors
+    DataArray(v)
+    Dataset({"a": v})
+    Dataset({"a": da})
+
+
+@parametrize_dim
+def test_dataset_variable_hashable_names(dim: DimT) -> None:
+    Dataset({dim: ("x", [1, 2, 3])})