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

problem_statement: Dataset.copy(deep=True) does not deepcopy .attrs
But it would be expected (at least by me) that it does.

date: 2022-09-28
patch: diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -853,25 +853,23 @@ def loc(self) -> _LocIndexer:
         return _LocIndexer(self)
 
     @property
-    # Key type needs to be `Any` because of mypy#4167
     def attrs(self) -> dict[Any, Any]:
         """Dictionary storing arbitrary metadata with this array."""
         return self.variable.attrs
 
     @attrs.setter
     def attrs(self, value: Mapping[Any, Any]) -> None:
-        # Disable type checking to work around mypy bug - see mypy#4167
-        self.variable.attrs = value  # type: ignore[assignment]
+        self.variable.attrs = dict(value)
 
     @property
-    def encoding(self) -> dict[Hashable, Any]:
+    def encoding(self) -> dict[Any, Any]:
         """Dictionary of format-specific settings for how this array should be
         serialized."""
         return self.variable.encoding
 
     @encoding.setter
     def encoding(self, value: Mapping[Any, Any]) -> None:
-        self.variable.encoding = value
+        self.variable.encoding = dict(value)
 
     @property
     def indexes(self) -> Indexes:
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -633,7 +633,7 @@ def variables(self) -> Frozen[Hashable, Variable]:
         return Frozen(self._variables)
 
     @property
-    def attrs(self) -> dict[Hashable, Any]:
+    def attrs(self) -> dict[Any, Any]:
         """Dictionary of global attributes on this dataset"""
         if self._attrs is None:
             self._attrs = {}
@@ -644,7 +644,7 @@ def attrs(self, value: Mapping[Any, Any]) -> None:
         self._attrs = dict(value)
 
     @property
-    def encoding(self) -> dict[Hashable, Any]:
+    def encoding(self) -> dict[Any, Any]:
         """Dictionary of global encoding attributes on this dataset"""
         if self._encoding is None:
             self._encoding = {}
@@ -1123,7 +1123,7 @@ def _overwrite_indexes(
             return replaced
 
     def copy(
-        self: T_Dataset, deep: bool = False, data: Mapping | None = None
+        self: T_Dataset, deep: bool = False, data: Mapping[Any, ArrayLike] | None = None
     ) -> T_Dataset:
         """Returns a copy of this dataset.
 
@@ -1252,8 +1252,9 @@ def copy(
                 variables[k] = v.copy(deep=deep, data=data.get(k))
 
         attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
+        encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
 
-        return self._replace(variables, indexes=indexes, attrs=attrs)
+        return self._replace(variables, indexes=indexes, attrs=attrs, encoding=encoding)
 
     def as_numpy(self: T_Dataset) -> T_Dataset:
         """
diff --git a/xarray/core/indexes.py b/xarray/core/indexes.py
--- a/xarray/core/indexes.py
+++ b/xarray/core/indexes.py
@@ -1107,7 +1107,7 @@ def dims(self) -> Mapping[Hashable, int]:
 
         return Frozen(self._dims)
 
-    def copy(self):
+    def copy(self) -> Indexes:
         return type(self)(dict(self._indexes), dict(self._variables))
 
     def get_unique(self) -> list[T_PandasOrXarrayIndex]:
diff --git a/xarray/core/variable.py b/xarray/core/variable.py
--- a/xarray/core/variable.py
+++ b/xarray/core/variable.py
@@ -894,7 +894,7 @@ def __setitem__(self, key, value):
         indexable[index_tuple] = value
 
     @property
-    def attrs(self) -> dict[Hashable, Any]:
+    def attrs(self) -> dict[Any, Any]:
         """Dictionary of local attributes on this variable."""
         if self._attrs is None:
             self._attrs = {}
@@ -905,7 +905,7 @@ def attrs(self, value: Mapping[Any, Any]) -> None:
         self._attrs = dict(value)
 
     @property
-    def encoding(self):
+    def encoding(self) -> dict[Any, Any]:
         """Dictionary of encodings on this variable."""
         if self._encoding is None:
             self._encoding = {}
@@ -918,7 +918,7 @@ def encoding(self, value):
         except ValueError:
             raise ValueError("encoding must be castable to a dictionary")
 
-    def copy(self, deep=True, data=None):
+    def copy(self, deep: bool = True, data: ArrayLike | None = None):
         """Returns a copy of this object.
 
         If `deep=True`, the data array is loaded into memory and copied onto
@@ -929,7 +929,7 @@ def copy(self, deep=True, data=None):
 
         Parameters
         ----------
-        deep : bool, optional
+        deep : bool, default: True
             Whether the data array is loaded into memory and copied onto
             the new object. Default is True.
         data : array_like, optional
@@ -975,28 +975,29 @@ def copy(self, deep=True, data=None):
         pandas.DataFrame.copy
         """
         if data is None:
-            data = self._data
+            ndata = self._data
 
-            if isinstance(data, indexing.MemoryCachedArray):
+            if isinstance(ndata, indexing.MemoryCachedArray):
                 # don't share caching between copies
-                data = indexing.MemoryCachedArray(data.array)
+                ndata = indexing.MemoryCachedArray(ndata.array)
 
             if deep:
-                data = copy.deepcopy(data)
+                ndata = copy.deepcopy(ndata)
 
         else:
-            data = as_compatible_data(data)
-            if self.shape != data.shape:
+            ndata = as_compatible_data(data)
+            if self.shape != ndata.shape:
                 raise ValueError(
                     "Data shape {} must match shape of object {}".format(
-                        data.shape, self.shape
+                        ndata.shape, self.shape
                     )
                 )
 
-        # note:
-        # dims is already an immutable tuple
-        # attributes and encoding will be copied when the new Array is created
-        return self._replace(data=data)
+        attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
+        encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
+
+        # note: dims is already an immutable tuple
+        return self._replace(data=ndata, attrs=attrs, encoding=encoding)
 
     def _replace(
         self: T_Variable,
@@ -2877,7 +2878,7 @@ def concat(
 
         return cls(first_var.dims, data, attrs)
 
-    def copy(self, deep=True, data=None):
+    def copy(self, deep: bool = True, data: ArrayLike | None = None):
         """Returns a copy of this object.
 
         `deep` is ignored since data is stored in the form of
@@ -2889,7 +2890,7 @@ def copy(self, deep=True, data=None):
 
         Parameters
         ----------
-        deep : bool, optional
+        deep : bool, default: True
             Deep is ignored when data is given. Whether the data array is
             loaded into memory and copied onto the new object. Default is True.
         data : array_like, optional
@@ -2902,16 +2903,20 @@ def copy(self, deep=True, data=None):
             data copied from original.
         """
         if data is None:
-            data = self._data.copy(deep=deep)
+            ndata = self._data.copy(deep=deep)
         else:
-            data = as_compatible_data(data)
-            if self.shape != data.shape:
+            ndata = as_compatible_data(data)
+            if self.shape != ndata.shape:
                 raise ValueError(
                     "Data shape {} must match shape of object {}".format(
-                        data.shape, self.shape
+                        ndata.shape, self.shape
                     )
                 )
-        return self._replace(data=data)
+
+        attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
+        encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
+
+        return self._replace(data=ndata, attrs=attrs, encoding=encoding)
 
     def equals(self, other, equiv=None):
         # if equiv is specified, super up

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
@@ -6458,7 +6458,6 @@ def test_delete_coords() -> None:
     assert set(a1.coords.keys()) == {"x"}
 
 
-@pytest.mark.xfail
 def test_deepcopy_nested_attrs() -> None:
     """Check attrs deep copy, see :issue:`2835`"""
     da1 = xr.DataArray([[1, 2], [3, 4]], dims=("x", "y"), coords={"x": [10, 20]})


----

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/xarray/core/dataarray.py b/xarray/core/dataarray.py
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -516,7 +516,7 @@ def _overwrite_indexes(
             new_indexes.pop(name)
 
         if rename_dims:
-            new_variable.dims = [rename_dims.get(d, d) for d in new_variable.dims]
+            new_variable.dims = tuple(rename_dims.get(d, d) for d in new_variable.dims)
 
         return self._replace(
             variable=new_variable, coords=new_coords, indexes=new_indexes
@@ -1169,7 +1169,15 @@ def copy(self: T_DataArray, deep: bool = True, data: Any = None) -> T_DataArray:
         --------
         pandas.DataFrame.copy
         """
-        variable = self.variable.copy(deep=deep, data=data)
+        return self._copy(deep=deep, data=data)
+
+    def _copy(
+        self: T_DataArray,
+        deep: bool = True,
+        data: Any = None,
+        memo: dict[int, Any] | None = None,
+    ) -> T_DataArray:
+        variable = self.variable._copy(deep=deep, data=data, memo=memo)
         indexes, index_vars = self.xindexes.copy_indexes(deep=deep)
 
         coords = {}
@@ -1177,17 +1185,17 @@ def copy(self: T_DataArray, deep: bool = True, data: Any = None) -> T_DataArray:
             if k in index_vars:
                 coords[k] = index_vars[k]
             else:
-                coords[k] = v.copy(deep=deep)
+                coords[k] = v._copy(deep=deep, memo=memo)
 
         return self._replace(variable, coords, indexes=indexes)
 
     def __copy__(self: T_DataArray) -> T_DataArray:
-        return self.copy(deep=False)
+        return self._copy(deep=False)
 
-    def __deepcopy__(self: T_DataArray, memo=None) -> T_DataArray:
-        # memo does nothing but is required for compatibility with
-        # copy.deepcopy
-        return self.copy(deep=True)
+    def __deepcopy__(
+        self: T_DataArray, memo: dict[int, Any] | None = None
+    ) -> T_DataArray:
+        return self._copy(deep=True, memo=memo)
 
     # mutable objects should not be Hashable
     # https://github.com/python/mypy/issues/4266
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -1221,6 +1221,14 @@ def copy(
         --------
         pandas.DataFrame.copy
         """
+        return self._copy(deep=deep, data=data)
+
+    def _copy(
+        self: T_Dataset,
+        deep: bool = False,
+        data: Mapping[Any, ArrayLike] | None = None,
+        memo: dict[int, Any] | None = None,
+    ) -> T_Dataset:
         if data is None:
             data = {}
         elif not utils.is_dict_like(data):
@@ -1249,13 +1257,21 @@ def copy(
             if k in index_vars:
                 variables[k] = index_vars[k]
             else:
-                variables[k] = v.copy(deep=deep, data=data.get(k))
+                variables[k] = v._copy(deep=deep, data=data.get(k), memo=memo)
 
-        attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
-        encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
+        attrs = copy.deepcopy(self._attrs, memo) if deep else copy.copy(self._attrs)
+        encoding = (
+            copy.deepcopy(self._encoding, memo) if deep else copy.copy(self._encoding)
+        )
 
         return self._replace(variables, indexes=indexes, attrs=attrs, encoding=encoding)
 
+    def __copy__(self: T_Dataset) -> T_Dataset:
+        return self._copy(deep=False)
+
+    def __deepcopy__(self: T_Dataset, memo: dict[int, Any] | None = None) -> T_Dataset:
+        return self._copy(deep=True, memo=memo)
+
     def as_numpy(self: T_Dataset) -> T_Dataset:
         """
         Coerces wrapped data and coordinates into numpy arrays, returning a Dataset.
@@ -1332,14 +1348,6 @@ def _construct_dataarray(self, name: Hashable) -> DataArray:
 
         return DataArray(variable, coords, name=name, indexes=indexes, fastpath=True)
 
-    def __copy__(self: T_Dataset) -> T_Dataset:
-        return self.copy(deep=False)
-
-    def __deepcopy__(self: T_Dataset, memo=None) -> T_Dataset:
-        # memo does nothing but is required for compatibility with
-        # copy.deepcopy
-        return self.copy(deep=True)
-
     @property
     def _attr_sources(self) -> Iterable[Mapping[Hashable, Any]]:
         """Places to look-up items for attribute-style access"""
diff --git a/xarray/core/formatting.py b/xarray/core/formatting.py
--- a/xarray/core/formatting.py
+++ b/xarray/core/formatting.py
@@ -8,6 +8,7 @@
 from collections import defaultdict
 from datetime import datetime, timedelta
 from itertools import chain, zip_longest
+from reprlib import recursive_repr
 from typing import Collection, Hashable
 
 import numpy as np
@@ -385,7 +386,6 @@ def _mapping_repr(
     expand_option_name="display_expand_data_vars",
 )
 
-
 attrs_repr = functools.partial(
     _mapping_repr,
     title="Attributes",
@@ -551,6 +551,7 @@ def short_data_repr(array):
         return f"[{array.size} values with dtype={array.dtype}]"
 
 
+@recursive_repr("<recursive array>")
 def array_repr(arr):
     from .variable import Variable
 
@@ -592,11 +593,12 @@ def array_repr(arr):
             summary.append(unindexed_dims_str)
 
     if arr.attrs:
-        summary.append(attrs_repr(arr.attrs))
+        summary.append(attrs_repr(arr.attrs, max_rows=max_rows))
 
     return "\n".join(summary)
 
 
+@recursive_repr("<recursive Dataset>")
 def dataset_repr(ds):
     summary = [f"<xarray.{type(ds).__name__}>"]
 
diff --git a/xarray/core/utils.py b/xarray/core/utils.py
--- a/xarray/core/utils.py
+++ b/xarray/core/utils.py
@@ -161,16 +161,13 @@ def equivalent(first: T, second: T) -> bool:
     # TODO: refactor to avoid circular import
     from . import duck_array_ops
 
+    if first is second:
+        return True
     if isinstance(first, np.ndarray) or isinstance(second, np.ndarray):
         return duck_array_ops.array_equiv(first, second)
-    elif isinstance(first, list) or isinstance(second, list):
+    if isinstance(first, list) or isinstance(second, list):
         return list_equiv(first, second)
-    else:
-        return (
-            (first is second)
-            or (first == second)
-            or (pd.isnull(first) and pd.isnull(second))
-        )
+    return (first == second) or (pd.isnull(first) and pd.isnull(second))
 
 
 def list_equiv(first, second):
diff --git a/xarray/core/variable.py b/xarray/core/variable.py
--- a/xarray/core/variable.py
+++ b/xarray/core/variable.py
@@ -918,7 +918,9 @@ def encoding(self, value):
         except ValueError:
             raise ValueError("encoding must be castable to a dictionary")
 
-    def copy(self, deep: bool = True, data: ArrayLike | None = None):
+    def copy(
+        self: T_Variable, deep: bool = True, data: ArrayLike | None = None
+    ) -> T_Variable:
         """Returns a copy of this object.
 
         If `deep=True`, the data array is loaded into memory and copied onto
@@ -974,6 +976,14 @@ def copy(self, deep: bool = True, data: ArrayLike | None = None):
         --------
         pandas.DataFrame.copy
         """
+        return self._copy(deep=deep, data=data)
+
+    def _copy(
+        self: T_Variable,
+        deep: bool = True,
+        data: ArrayLike | None = None,
+        memo: dict[int, Any] | None = None,
+    ) -> T_Variable:
         if data is None:
             ndata = self._data
 
@@ -982,7 +992,7 @@ def copy(self, deep: bool = True, data: ArrayLike | None = None):
                 ndata = indexing.MemoryCachedArray(ndata.array)
 
             if deep:
-                ndata = copy.deepcopy(ndata)
+                ndata = copy.deepcopy(ndata, memo)
 
         else:
             ndata = as_compatible_data(data)
@@ -993,8 +1003,10 @@ def copy(self, deep: bool = True, data: ArrayLike | None = None):
                     )
                 )
 
-        attrs = copy.deepcopy(self._attrs) if deep else copy.copy(self._attrs)
-        encoding = copy.deepcopy(self._encoding) if deep else copy.copy(self._encoding)
+        attrs = copy.deepcopy(self._attrs, memo) if deep else copy.copy(self._attrs)
+        encoding = (
+            copy.deepcopy(self._encoding, memo) if deep else copy.copy(self._encoding)
+        )
 
         # note: dims is already an immutable tuple
         return self._replace(data=ndata, attrs=attrs, encoding=encoding)
@@ -1016,13 +1028,13 @@ def _replace(
             encoding = copy.copy(self._encoding)
         return type(self)(dims, data, attrs, encoding, fastpath=True)
 
-    def __copy__(self):
-        return self.copy(deep=False)
+    def __copy__(self: T_Variable) -> T_Variable:
+        return self._copy(deep=False)
 
-    def __deepcopy__(self, memo=None):
-        # memo does nothing but is required for compatibility with
-        # copy.deepcopy
-        return self.copy(deep=True)
+    def __deepcopy__(
+        self: T_Variable, memo: dict[int, Any] | None = None
+    ) -> T_Variable:
+        return self._copy(deep=True, memo=memo)
 
     # mutable objects should not be hashable
     # https://github.com/python/mypy/issues/4266

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"):