Repository: pydata/xarray (2022-09-28 ~ 2022-10-07)

problem_statement: Broken state when using assign_coords with multiindex
### What happened?

I was trying to assign coordinates on a dataset that had been created by using stack. After assigning the coordinates, the dataset was in a state where its length was coming out as less than zero, which caused all sorts of issues. 

### What did you expect to happen?

I think the issue is with the updating of `_coord_names`, perhaps in https://github.com/pydata/xarray/blob/18454c218002e48e1643ce8e25654262e5f592ad/xarray/core/coordinates.py#L389.

I expected to just be able to assign the coords and then print the array to see the result.

### Minimal Complete Verifiable Example

```Python
import xarray as xr


ds = xr.DataArray(
    [[[1, 1], [0, 0]], [[2, 2], [1, 1]]],
    dims=("lat", "year", "month"),
    coords={"lat": [-60, 60], "year": [2010, 2020], "month": [3, 6]},
    name="test",
).to_dataset()

stacked = ds.stack(time=("year", "month"))
stacked = stacked.assign_coords(
    {"time": [y + m / 12 for y, m in stacked["time"].values]}
)

# Both these fail with ValueError: __len__() should return >= 0
len(stacked)
print(stacked)
```


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

### Relevant log output

```Python
Traceback (most recent call last):
  File "mre.py", line 17, in <module>
    len(stacked)
  File ".../xarray-tests/xarray/core/dataset.py", line 1364, in __len__
    return len(self.data_vars)
ValueError: __len__() should return >= 0
```


### Anything else we need to know?

Here's a test (I put it in `test_dataarray.py` but maybe there is a better spot)

```python
def test_assign_coords_drop_coord_names(self) -> None:
        ds = DataArray(
            [[[1, 1], [0, 0]], [[2, 2], [1, 1]]],
            dims=("lat", "year", "month"),
            coords={"lat": [-60, 60], "year": [2010, 2020], "month": [3, 6]},
            name="test",
        ).to_dataset()

        stacked = ds.stack(time=("year", "month"))
        stacked = stacked.assign_coords(
            {"time": [y + m / 12 for y, m in stacked["time"].values]}
        )

        # this seems to be handled correctly
        assert set(stacked._variables.keys()) == {"test", "time", "lat"}
        # however, _coord_names doesn't seem to update as expected
        # the below fails
        assert set(stacked._coord_names) == {"time", "lat"}

        # the incorrect value of _coord_names means that all the below fails too
        # The failure is because the length of a dataset is calculated as (via len(data_vars))
        # len(dataset._variables) - len(dataset._coord_names). For the situation
        # above, where len(dataset._coord_names) is greater than len(dataset._variables),
        # you get a length less than zero which then fails because length must return
        # a value greater than zero

        # Both these fail with ValueError: __len__() should return >= 0
        len(stacked)
        print(stacked)
```

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: e678a1d7884a3c24dba22d41b2eef5d7fe5258e7
python: 3.8.13 | packaged by conda-forge | (default, Mar 25 2022, 06:04:14) 
[Clang 12.0.1 ]
python-bits: 64
OS: Darwin
OS-release: 21.5.0
machine: arm64
processor: arm
byteorder: little
LC_ALL: None
LANG: en_AU.UTF-8
LOCALE: ('en_AU', 'UTF-8')
libhdf5: 1.12.2
libnetcdf: 4.8.1

xarray: 0.1.dev4312+ge678a1d.d20220928
pandas: 1.5.0
numpy: 1.22.4
scipy: 1.9.1
netCDF4: 1.6.1
pydap: installed
h5netcdf: 1.0.2
h5py: 3.7.0
Nio: None
zarr: 2.13.2
cftime: 1.6.2
nc_time_axis: 1.4.1
PseudoNetCDF: 3.2.2
rasterio: 1.3.1
cfgrib: 0.9.10.1
iris: 3.3.0
bottleneck: 1.3.5
dask: 2022.9.1
distributed: 2022.9.1
matplotlib: 3.6.0
cartopy: 0.21.0
seaborn: 0.12.0
numbagg: 0.2.1
fsspec: 2022.8.2
cupy: None
pint: 0.19.2
sparse: 0.13.0
flox: 0.5.9
numpy_groupies: 0.9.19
setuptools: 65.4.0
pip: 22.2.2
conda: None
pytest: 7.1.3
IPython: None
sphinx: None

</details>


date: 2022-09-28
patch: diff --git a/xarray/core/coordinates.py b/xarray/core/coordinates.py
--- a/xarray/core/coordinates.py
+++ b/xarray/core/coordinates.py
@@ -315,6 +315,7 @@ def _maybe_drop_multiindex_coords(self, coords: set[Hashable]) -> None:
         variables, indexes = drop_coords(
             coords, self._data._variables, self._data.xindexes
         )
+        self._data._coord_names.intersection_update(variables)
         self._data._variables = variables
         self._data._indexes = indexes
 
@@ -441,7 +442,7 @@ def drop_coords(
                 f"other variables: {list(maybe_midx.index.names)!r}. "
                 f"This will raise an error in the future. Use `.drop_vars({idx_coord_names!r})` before "
                 "assigning new coordinate values.",
-                DeprecationWarning,
+                FutureWarning,
                 stacklevel=4,
             )
             for k in idx_coord_names:

test_patch: diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -1501,9 +1501,7 @@ def test_assign_coords(self) -> None:
 
     def test_assign_coords_existing_multiindex(self) -> None:
         data = self.mda
-        with pytest.warns(
-            DeprecationWarning, match=r"Updating MultiIndexed coordinate"
-        ):
+        with pytest.warns(FutureWarning, match=r"Updating MultiIndexed coordinate"):
             data.assign_coords(x=range(4))
 
     def test_coords_alignment(self) -> None:
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -4136,16 +4136,16 @@ def test_assign_multiindex_level(self) -> None:
 
     def test_assign_coords_existing_multiindex(self) -> None:
         data = create_test_multiindex()
-        with pytest.warns(
-            DeprecationWarning, match=r"Updating MultiIndexed coordinate"
-        ):
+        with pytest.warns(FutureWarning, match=r"Updating MultiIndexed coordinate"):
             data.assign_coords(x=range(4))
 
-        with pytest.warns(
-            DeprecationWarning, match=r"Updating MultiIndexed coordinate"
-        ):
+        with pytest.warns(FutureWarning, match=r"Updating MultiIndexed coordinate"):
             data.assign(x=range(4))
 
+        # https://github.com/pydata/xarray/issues/7097 (coord names updated)
+        updated = data.assign_coords(x=range(4))
+        assert len(updated.coords) == 1
+
     def test_assign_all_multiindex_coords(self) -> None:
         data = create_test_multiindex()
         actual = data.assign(x=range(4), level_1=range(4), level_2=range(4))


----

problem_statement: New deep copy behavior in 2022.9.0 causes maximum recursion error
### What happened?

I have a case where a Dataset to be written to a NetCDF file has "ancillary_variables" that have a circular dependence. For example, variable A has `.attrs["ancillary_variables"]` that contains variable B, and B has `.attrs["ancillary_variables"]` that contains A.

### What did you expect to happen?

Circular dependencies are detected and avoided. No maximum recursion error.

### Minimal Complete Verifiable Example

```Python
In [1]: import xarray as xr

In [2]: a = xr.DataArray(1.0, attrs={})

In [3]: b = xr.DataArray(2.0, attrs={})

In [4]: a.attrs["other"] = b

In [5]: b.attrs["other"] = a

In [6]: a_copy = a.copy(deep=True)
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
Cell In [6], line 1
----> 1 a_copy = a.copy(deep=True)

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/dataarray.py:1172, in DataArray.copy(self, deep, data)
   1104 def copy(self: T_DataArray, deep: bool = True, data: Any = None) -> T_DataArray:
   1105     """Returns a copy of this array.
   1106 
   1107     If `deep=True`, a deep copy is made of the data array.
   (...)
   1170     pandas.DataFrame.copy
   1171     """
-> 1172     variable = self.variable.copy(deep=deep, data=data)
   1173     indexes, index_vars = self.xindexes.copy_indexes(deep=deep)
   1175     coords = {}

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/variable.py:996, in Variable.copy(self, deep, data)
    989     if self.shape != ndata.shape:
    990         raise ValueError(
    991             "Data shape {} must match shape of object {}".format(
    992                 ndata.shape, self.shape
    993             )
    994         )
--> 996 attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
    997 encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
    999 # note: dims is already an immutable tuple

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:146, in deepcopy(x, memo, _nil)
    144 copier = _deepcopy_dispatch.get(cls)
    145 if copier is not None:
--> 146     y = copier(x, memo)
    147 else:
    148     if issubclass(cls, type):

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:231, in _deepcopy_dict(x, memo, deepcopy)
    229 memo[id(x)] = y
    230 for key, value in x.items():
--> 231     y[deepcopy(key, memo)] = deepcopy(value, memo)
    232 return y

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:153, in deepcopy(x, memo, _nil)
    151 copier = getattr(x, "__deepcopy__", None)
    152 if copier is not None:
--> 153     y = copier(memo)
    154 else:
    155     reductor = dispatch_table.get(cls)

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/dataarray.py:1190, in DataArray.__deepcopy__(self, memo)
   1187 def __deepcopy__(self: T_DataArray, memo=None) -> T_DataArray:
   1188     # memo does nothing but is required for compatibility with
   1189     # copy.deepcopy
-> 1190     return self.copy(deep=True)

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/dataarray.py:1172, in DataArray.copy(self, deep, data)
   1104 def copy(self: T_DataArray, deep: bool = True, data: Any = None) -> T_DataArray:
   1105     """Returns a copy of this array.
   1106 
   1107     If `deep=True`, a deep copy is made of the data array.
   (...)
   1170     pandas.DataFrame.copy
   1171     """
-> 1172     variable = self.variable.copy(deep=deep, data=data)
   1173     indexes, index_vars = self.xindexes.copy_indexes(deep=deep)
   1175     coords = {}

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/variable.py:996, in Variable.copy(self, deep, data)
    989     if self.shape != ndata.shape:
    990         raise ValueError(
    991             "Data shape {} must match shape of object {}".format(
    992                 ndata.shape, self.shape
    993             )
    994         )
--> 996 attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
    997 encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
    999 # note: dims is already an immutable tuple

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:146, in deepcopy(x, memo, _nil)
    144 copier = _deepcopy_dispatch.get(cls)
    145 if copier is not None:
--> 146     y = copier(x, memo)
    147 else:
    148     if issubclass(cls, type):

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:231, in _deepcopy_dict(x, memo, deepcopy)
    229 memo[id(x)] = y
    230 for key, value in x.items():
--> 231     y[deepcopy(key, memo)] = deepcopy(value, memo)
    232 return y

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:153, in deepcopy(x, memo, _nil)
    151 copier = getattr(x, "__deepcopy__", None)
    152 if copier is not None:
--> 153     y = copier(memo)
    154 else:
    155     reductor = dispatch_table.get(cls)

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/dataarray.py:1190, in DataArray.__deepcopy__(self, memo)
   1187 def __deepcopy__(self: T_DataArray, memo=None) -> T_DataArray:
   1188     # memo does nothing but is required for compatibility with
   1189     # copy.deepcopy
-> 1190     return self.copy(deep=True)

    [... skipping similar frames: DataArray.copy at line 1172 (495 times), DataArray.__deepcopy__ at line 1190 (494 times), _deepcopy_dict at line 231 (494 times), Variable.copy at line 996 (494 times), deepcopy at line 146 (494 times), deepcopy at line 153 (494 times)]

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/variable.py:996, in Variable.copy(self, deep, data)
    989     if self.shape != ndata.shape:
    990         raise ValueError(
    991             "Data shape {} must match shape of object {}".format(
    992                 ndata.shape, self.shape
    993             )
    994         )
--> 996 attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
    997 encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
    999 # note: dims is already an immutable tuple

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:146, in deepcopy(x, memo, _nil)
    144 copier = _deepcopy_dispatch.get(cls)
    145 if copier is not None:
--> 146     y = copier(x, memo)
    147 else:
    148     if issubclass(cls, type):

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:231, in _deepcopy_dict(x, memo, deepcopy)
    229 memo[id(x)] = y
    230 for key, value in x.items():
--> 231     y[deepcopy(key, memo)] = deepcopy(value, memo)
    232 return y

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:153, in deepcopy(x, memo, _nil)
    151 copier = getattr(x, "__deepcopy__", None)
    152 if copier is not None:
--> 153     y = copier(memo)
    154 else:
    155     reductor = dispatch_table.get(cls)

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/dataarray.py:1190, in DataArray.__deepcopy__(self, memo)
   1187 def __deepcopy__(self: T_DataArray, memo=None) -> T_DataArray:
   1188     # memo does nothing but is required for compatibility with
   1189     # copy.deepcopy
-> 1190     return self.copy(deep=True)

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/dataarray.py:1172, in DataArray.copy(self, deep, data)
   1104 def copy(self: T_DataArray, deep: bool = True, data: Any = None) -> T_DataArray:
   1105     """Returns a copy of this array.
   1106
   1107     If `deep=True`, a deep copy is made of the data array.
   (...)
   1170     pandas.DataFrame.copy
   1171     """
-> 1172     variable = self.variable.copy(deep=deep, data=data)
   1173     indexes, index_vars = self.xindexes.copy_indexes(deep=deep)
   1175     coords = {}

File ~/miniconda3/envs/satpy_py310/lib/python3.10/site-packages/xarray/core/variable.py:985, in Variable.copy(self, deep, data)
    982         ndata = indexing.MemoryCachedArray(ndata.array)
    984     if deep:
--> 985         ndata = copy.deepcopy(ndata)
    987 else:
    988     ndata = as_compatible_data(data)

File ~/miniconda3/envs/satpy_py310/lib/python3.10/copy.py:137, in deepcopy(x, memo, _nil)
    134 if memo is None:
    135     memo = {}
--> 137 d = id(x)
    138 y = memo.get(d, _nil)
    139 if y is not _nil:

RecursionError: maximum recursion depth exceeded while calling a Python object
```


### 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.

### Relevant log output

_No response_

### Anything else we need to know?

I have at least one other issue related to the new xarray release but I'm still tracking it down. I think it is also related to the deep copy behavior change which was merged a day before the release so our CI didn't have time to test the "unstable" version of xarray.

### Environment

<details>

```
INSTALLED VERSIONS
------------------
commit: None
python: 3.10.6 | packaged by conda-forge | (main, Aug 22 2022, 20:35:26) [GCC 10.4.0]
python-bits: 64
OS: Linux
OS-release: 5.19.0-76051900-generic
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.12.2
libnetcdf: 4.8.1

xarray: 2022.9.0
pandas: 1.5.0
numpy: 1.23.3
scipy: 1.9.1
netCDF4: 1.6.1
pydap: None
h5netcdf: 1.0.2
h5py: 3.7.0
Nio: None
zarr: 2.13.2
cftime: 1.6.2
nc_time_axis: None
PseudoNetCDF: None
rasterio: 1.3.2
cfgrib: None
iris: None
bottleneck: 1.3.5
dask: 2022.9.1
distributed: 2022.9.1
matplotlib: 3.6.0
cartopy: 0.21.0
seaborn: None
numbagg: None
fsspec: 2022.8.2
cupy: None
pint: None
sparse: None
flox: None
numpy_groupies: None
setuptools: 65.4.0
pip: 22.2.2
conda: None
pytest: 7.1.3
IPython: 8.5.0
sphinx: 5.2.3
```

</details>


date: 2022-10-06
patch: diff --git a/.github/workflows/parse_logs.py b/.github/workflows/parse_logs.py
--- a/.github/workflows/parse_logs.py
+++ b/.github/workflows/parse_logs.py
@@ -1,4 +1,5 @@
 # type: ignore
+# noop comment for filter
 import argparse
 import functools
 import json

test_patch: diff --git a/xarray/tests/test_concat.py b/xarray/tests/test_concat.py
--- a/xarray/tests/test_concat.py
+++ b/xarray/tests/test_concat.py
@@ -219,7 +219,9 @@ def test_concat_errors(self):
             concat([data, data], "new_dim", coords=["not_found"])
 
         with pytest.raises(ValueError, match=r"global attributes not"):
-            data0, data1 = deepcopy(split_data)
+            # call deepcopy seperately to get unique attrs
+            data0 = deepcopy(split_data[0])
+            data1 = deepcopy(split_data[1])
             data1.attrs["foo"] = "bar"
             concat([data0, data1], "dim1", compat="identical")
         assert_identical(data, concat([data0, data1], "dim1", compat="equals"))
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -6488,6 +6488,28 @@ def test_deepcopy_obj_array() -> None:
     assert x0.values[0] is not x1.values[0]
 
 
+def test_deepcopy_recursive() -> None:
+    # GH:issue:7111
+
+    # direct recursion
+    da = xr.DataArray([1, 2], dims=["x"])
+    da.attrs["other"] = da
+
+    # TODO: cannot use assert_identical on recursive Vars yet...
+    # lets just ensure that deep copy works without RecursionError
+    da.copy(deep=True)
+
+    # indirect recursion
+    da2 = xr.DataArray([5, 6], dims=["y"])
+    da.attrs["other"] = da2
+    da2.attrs["other"] = da
+
+    # TODO: cannot use assert_identical on recursive Vars yet...
+    # lets just ensure that deep copy works without RecursionError
+    da.copy(deep=True)
+    da2.copy(deep=True)
+
+
 def test_clip(da: DataArray) -> None:
     with raise_if_dask_computes():
         result = da.clip(min=0.5)
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -6687,6 +6687,28 @@ def test_deepcopy_obj_array() -> None:
     assert x0["foo"].values[0] is not x1["foo"].values[0]
 
 
+def test_deepcopy_recursive() -> None:
+    # GH:issue:7111
+
+    # direct recursion
+    ds = xr.Dataset({"a": (["x"], [1, 2])})
+    ds.attrs["other"] = ds
+
+    # TODO: cannot use assert_identical on recursive Vars yet...
+    # lets just ensure that deep copy works without RecursionError
+    ds.copy(deep=True)
+
+    # indirect recursion
+    ds2 = xr.Dataset({"b": (["y"], [3, 4])})
+    ds.attrs["other"] = ds2
+    ds2.attrs["other"] = ds
+
+    # TODO: cannot use assert_identical on recursive Vars yet...
+    # lets just ensure that deep copy works without RecursionError
+    ds.copy(deep=True)
+    ds2.copy(deep=True)
+
+
 def test_clip(ds) -> None:
     result = ds.clip(min=0.5)
     assert all((result.min(...) >= 0.5).values())
diff --git a/xarray/tests/test_formatting.py b/xarray/tests/test_formatting.py
--- a/xarray/tests/test_formatting.py
+++ b/xarray/tests/test_formatting.py
@@ -431,6 +431,24 @@ def test_array_repr_variable(self) -> None:
         with xr.set_options(display_expand_data=False):
             formatting.array_repr(var)
 
+    def test_array_repr_recursive(self) -> None:
+        # GH:issue:7111
+
+        # direct recurion
+        var = xr.Variable("x", [0, 1])
+        var.attrs["x"] = var
+        formatting.array_repr(var)
+
+        da = xr.DataArray([0, 1], dims=["x"])
+        da.attrs["x"] = da
+        formatting.array_repr(da)
+
+        # indirect recursion
+        var.attrs["x"] = da
+        da.attrs["x"] = var
+        formatting.array_repr(var)
+        formatting.array_repr(da)
+
     @requires_dask
     def test_array_scalar_format(self) -> None:
         # Test numpy scalars:
@@ -615,6 +633,21 @@ def test__mapping_repr(display_max_rows, n_vars, n_attr) -> None:
         assert actual == expected
 
 
+def test__mapping_repr_recursive() -> None:
+    # GH:issue:7111
+
+    # direct recursion
+    ds = xr.Dataset({"a": [["x"], [1, 2, 3]]})
+    ds.attrs["ds"] = ds
+    formatting.dataset_repr(ds)
+
+    # indirect recursion
+    ds2 = xr.Dataset({"b": [["y"], [1, 2, 3]]})
+    ds.attrs["ds"] = ds2
+    ds2.attrs["ds"] = ds
+    formatting.dataset_repr(ds2)
+
+
 def test__element_formatter(n_elements: int = 100) -> None:
     expected = """\
     Dimensions without coordinates: dim_0: 3, dim_1: 3, dim_2: 3, dim_3: 3,
diff --git a/xarray/tests/test_variable.py b/xarray/tests/test_variable.py
--- a/xarray/tests/test_variable.py
+++ b/xarray/tests/test_variable.py
@@ -59,6 +59,8 @@ def var():
 
 
 class VariableSubclassobjects:
+    cls: staticmethod[Variable]
+
     def test_properties(self):
         data = 0.5 * np.arange(10)
         v = self.cls(["time"], data, {"foo": "bar"})
@@ -521,7 +523,7 @@ def test_concat_mixed_dtypes(self):
 
     @pytest.mark.parametrize("deep", [True, False])
     @pytest.mark.parametrize("astype", [float, int, str])
-    def test_copy(self, deep, astype):
+    def test_copy(self, deep: bool, astype: type[object]) -> None:
         v = self.cls("x", (0.5 * np.arange(10)).astype(astype), {"foo": "bar"})
         w = v.copy(deep=deep)
         assert type(v) is type(w)
@@ -534,6 +536,27 @@ def test_copy(self, deep, astype):
                 assert source_ndarray(v.values) is source_ndarray(w.values)
         assert_identical(v, copy(v))
 
+    def test_copy_deep_recursive(self) -> None:
+        # GH:issue:7111
+
+        # direct recursion
+        v = self.cls("x", [0, 1])
+        v.attrs["other"] = v
+
+        # TODO: cannot use assert_identical on recursive Vars yet...
+        # lets just ensure that deep copy works without RecursionError
+        v.copy(deep=True)
+
+        # indirect recusrion
+        v2 = self.cls("y", [2, 3])
+        v.attrs["other"] = v2
+        v2.attrs["other"] = v
+
+        # TODO: cannot use assert_identical on recursive Vars yet...
+        # lets just ensure that deep copy works without RecursionError
+        v.copy(deep=True)
+        v2.copy(deep=True)
+
     def test_copy_index(self):
         midx = pd.MultiIndex.from_product(
             [["a", "b"], [1, 2], [-1, -2]], names=("one", "two", "three")
@@ -545,7 +568,7 @@ def test_copy_index(self):
             assert isinstance(w.to_index(), pd.MultiIndex)
             assert_array_equal(v._data.array, w._data.array)
 
-    def test_copy_with_data(self):
+    def test_copy_with_data(self) -> None:
         orig = Variable(("x", "y"), [[1.5, 2.0], [3.1, 4.3]], {"foo": "bar"})
         new_data = np.array([[2.5, 5.0], [7.1, 43]])
         actual = orig.copy(data=new_data)
@@ -553,20 +576,20 @@ def test_copy_with_data(self):
         expected.data = new_data
         assert_identical(expected, actual)
 
-    def test_copy_with_data_errors(self):
+    def test_copy_with_data_errors(self) -> None:
         orig = Variable(("x", "y"), [[1.5, 2.0], [3.1, 4.3]], {"foo": "bar"})
         new_data = [2.5, 5.0]
         with pytest.raises(ValueError, match=r"must match shape of object"):
             orig.copy(data=new_data)
 
-    def test_copy_index_with_data(self):
+    def test_copy_index_with_data(self) -> None:
         orig = IndexVariable("x", np.arange(5))
         new_data = np.arange(5, 10)
         actual = orig.copy(data=new_data)
         expected = IndexVariable("x", np.arange(5, 10))
         assert_identical(expected, actual)
 
-    def test_copy_index_with_data_errors(self):
+    def test_copy_index_with_data_errors(self) -> None:
         orig = IndexVariable("x", np.arange(5))
         new_data = np.arange(5, 20)
         with pytest.raises(ValueError, match=r"must match shape of object"):


----

problem_statement: groupby(multi-index level) not working correctly on a multi-indexed DataArray or DataSet
### What happened?

run the code block below with `2022.6.0`
```
midx = pd.MultiIndex.from_product([list("abc"), [0, 1]], names=("one", "two"))

mda = xr.DataArray(np.random.rand(6, 3), [("x", midx), ("y", range(3))])

mda.groupby("one").groups
```
output:
```
In [15]: mda.groupby("one").groups
Out[15]: 
{('a', 0): [0],
 ('a', 1): [1],
 ('b', 0): [2],
 ('b', 1): [3],
 ('c', 0): [4],
 ('c', 1): [5]}
```

### What did you expect to happen?

as it was with `2022.3.0`
```
In [6]: mda.groupby("one").groups
Out[6]: {'a': [0, 1], 'b': [2, 3], 'c': [4, 5]}
```

### Minimal Complete Verifiable Example

```Python
import pandas as pd
import numpy as np
import xarray as XR

midx = pd.MultiIndex.from_product([list("abc"), [0, 1]], names=("one", "two"))

mda = xr.DataArray(np.random.rand(6, 3), [("x", midx), ("y", range(3))])

mda.groupby("one").groups
```


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

### Relevant log output

```Python
N/A
```


### Anything else we need to know?

N/A

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.8.10 (default, Mar 15 2022, 12:22:08) 
[GCC 9.4.0]
python-bits: 64
OS: Linux
OS-release: 5.11.0-1025-aws
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: C.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.12.0
libnetcdf: 4.7.4

xarray: 2022.6.0
pandas: 1.4.3
numpy: 1.22.4
scipy: 1.7.3
netCDF4: 1.5.8
pydap: None
h5netcdf: None
h5py: None
Nio: None
zarr: None
cftime: 1.5.1.1
nc_time_axis: None
PseudoNetCDF: None
rasterio: 1.2.10
cfgrib: None
iris: None
bottleneck: 1.3.2
dask: 2022.04.1
distributed: 2022.4.1
matplotlib: 3.5.1
cartopy: 0.20.3
seaborn: 0.11.2
numbagg: None
fsspec: 2022.01.0
cupy: None
pint: None
sparse: None
flox: None
numpy_groupies: None
setuptools: 45.2.0
pip: 22.2
conda: None
pytest: 7.1.2
IPython: 7.31.0
sphinx: None
</details>


date: 2022-10-07
patch: diff --git a/.github/workflows/parse_logs.py b/.github/workflows/parse_logs.py
--- a/.github/workflows/parse_logs.py
+++ b/.github/workflows/parse_logs.py
@@ -1,4 +1,5 @@
 # type: ignore
+# noop comment for filter
 # noop comment for filter
 import argparse
 import functools

test_patch: diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -2737,7 +2737,7 @@ def test_copy(self) -> None:
             assert_identical(data, copied)
             assert data.encoding == copied.encoding
             # Note: IndexVariable objects with string dtype are always
-            # copied because of xarray.core.util.safe_cast_to_index.
+            # copied because of xarray.core.indexes.safe_cast_to_index.
             # Limiting the test to data variables.
             for k in data.data_vars:
                 v0 = data.variables[k]
diff --git a/xarray/tests/test_indexes.py b/xarray/tests/test_indexes.py
--- a/xarray/tests/test_indexes.py
+++ b/xarray/tests/test_indexes.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import copy
+from datetime import datetime
 from typing import Any
 
 import numpy as np
@@ -8,6 +9,7 @@
 import pytest
 
 import xarray as xr
+from xarray.coding.cftimeindex import CFTimeIndex
 from xarray.core.indexes import (
     Hashable,
     Index,
@@ -15,10 +17,12 @@
     PandasIndex,
     PandasMultiIndex,
     _asarray_tuplesafe,
+    safe_cast_to_index,
 )
 from xarray.core.variable import IndexVariable, Variable
 
-from . import assert_identical
+from . import assert_array_equal, assert_identical, requires_cftime
+from .test_coding_times import _all_cftime_date_types
 
 
 def test_asarray_tuplesafe() -> None:
@@ -656,3 +660,41 @@ def test_copy_indexes(self, indexes) -> None:
         assert index_vars.keys() == indexes.variables.keys()
         for new, original in zip(index_vars.values(), indexes.variables.values()):
             assert_identical(new, original)
+
+
+def test_safe_cast_to_index():
+    dates = pd.date_range("2000-01-01", periods=10)
+    x = np.arange(5)
+    td = x * np.timedelta64(1, "D")
+    for expected, array in [
+        (dates, dates.values),
+        (pd.Index(x, dtype=object), x.astype(object)),
+        (pd.Index(td), td),
+        (pd.Index(td, dtype=object), td.astype(object)),
+    ]:
+        actual = safe_cast_to_index(array)
+        assert_array_equal(expected, actual)
+        assert expected.dtype == actual.dtype
+
+
+@requires_cftime
+def test_safe_cast_to_index_cftimeindex():
+    date_types = _all_cftime_date_types()
+    for date_type in date_types.values():
+        dates = [date_type(1, 1, day) for day in range(1, 20)]
+        expected = CFTimeIndex(dates)
+        actual = safe_cast_to_index(np.array(dates))
+        assert_array_equal(expected, actual)
+        assert expected.dtype == actual.dtype
+        assert isinstance(actual, type(expected))
+
+
+# Test that datetime.datetime objects are never used in a CFTimeIndex
+@requires_cftime
+def test_safe_cast_to_index_datetime_datetime():
+    dates = [datetime(1, 1, day) for day in range(1, 20)]
+
+    expected = pd.Index(dates)
+    actual = safe_cast_to_index(np.array(dates))
+    assert_array_equal(expected, actual)
+    assert isinstance(actual, pd.Index)
diff --git a/xarray/tests/test_utils.py b/xarray/tests/test_utils.py
--- a/xarray/tests/test_utils.py
+++ b/xarray/tests/test_utils.py
@@ -1,18 +1,15 @@
 from __future__ import annotations
 
-from datetime import datetime
 from typing import Hashable
 
 import numpy as np
 import pandas as pd
 import pytest
 
-from xarray.coding.cftimeindex import CFTimeIndex
 from xarray.core import duck_array_ops, utils
 from xarray.core.utils import either_dict_or_kwargs, iterate_nested
 
-from . import assert_array_equal, requires_cftime, requires_dask
-from .test_coding_times import _all_cftime_date_types
+from . import assert_array_equal, requires_dask
 
 
 class TestAlias:
@@ -26,21 +23,6 @@ def new_method():
             old_method()
 
 
-def test_safe_cast_to_index():
-    dates = pd.date_range("2000-01-01", periods=10)
-    x = np.arange(5)
-    td = x * np.timedelta64(1, "D")
-    for expected, array in [
-        (dates, dates.values),
-        (pd.Index(x, dtype=object), x.astype(object)),
-        (pd.Index(td), td),
-        (pd.Index(td, dtype=object), td.astype(object)),
-    ]:
-        actual = utils.safe_cast_to_index(array)
-        assert_array_equal(expected, actual)
-        assert expected.dtype == actual.dtype
-
-
 @pytest.mark.parametrize(
     "a, b, expected", [["a", "b", np.array(["a", "b"])], [1, 2, pd.Index([1, 2])]]
 )
@@ -68,29 +50,6 @@ def test_maybe_coerce_to_str_minimal_str_dtype():
     assert expected.dtype == actual.dtype
 
 
-@requires_cftime
-def test_safe_cast_to_index_cftimeindex():
-    date_types = _all_cftime_date_types()
-    for date_type in date_types.values():
-        dates = [date_type(1, 1, day) for day in range(1, 20)]
-        expected = CFTimeIndex(dates)
-        actual = utils.safe_cast_to_index(np.array(dates))
-        assert_array_equal(expected, actual)
-        assert expected.dtype == actual.dtype
-        assert isinstance(actual, type(expected))
-
-
-# Test that datetime.datetime objects are never used in a CFTimeIndex
-@requires_cftime
-def test_safe_cast_to_index_datetime_datetime():
-    dates = [datetime(1, 1, day) for day in range(1, 20)]
-
-    expected = pd.Index(dates)
-    actual = utils.safe_cast_to_index(np.array(dates))
-    assert_array_equal(expected, actual)
-    assert isinstance(actual, pd.Index)
-
-
 class TestArrayEquiv:
     def test_0d(self):
         # verify our work around for pd.isnull not working for 0-dimensional
diff --git a/xarray/tests/test_variable.py b/xarray/tests/test_variable.py
--- a/xarray/tests/test_variable.py
+++ b/xarray/tests/test_variable.py
@@ -2300,6 +2300,11 @@ def test_to_index(self):
         v = IndexVariable(["time"], data, {"foo": "bar"})
         assert pd.Index(data, name="time").identical(v.to_index())
 
+    def test_to_index_multiindex_level(self):
+        midx = pd.MultiIndex.from_product([["a", "b"], [1, 2]], names=("one", "two"))
+        ds = Dataset(coords={"x": midx})
+        assert ds.one.variable.to_index().equals(midx.get_level_values("one"))
+
     def test_multiindex_default_level_names(self):
         midx = pd.MultiIndex.from_product([["a", "b"], [1, 2]])
         v = IndexVariable(["x"], midx, {"foo": "bar"})