Repository: pydata/xarray

Issue:
coarsen deletes attrs on original object
<!-- A short summary of the issue, if appropriate -->


#### MCVE Code Sample
<!-- In order for the maintainers to efficiently understand and prioritize issues, we ask you post a "Minimal, Complete and Verifiable Example" (MCVE): http://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports -->

```python
# Your code here
import xarray as xr

ds = xr.tutorial.load_dataset("air_temperature")
ds2 = xr.tutorial.load_dataset("air_temperature")

xr.testing.assert_identical(ds, ds2) # passes
ds.coarsen(lat=5).mean()
xr.testing.assert_identical(ds, ds2) # fails
```

Bug:  `coarsen` reductions are modifying the original dataset by deleting all attributes

```
AssertionErrorTraceback (most recent call last)
<ipython-input-21-b0a179f01c99> in <module>
     48 xr.testing.assert_identical(ds, ds2)
     49 ds.coarsen(lat=5).mean()
---> 50 xr.testing.assert_identical(ds, ds2)

~/work/python/xarray/xarray/testing.py in assert_identical(a, b)
     87         assert a.identical(b), formatting.diff_array_repr(a, b, "identical")
     88     elif isinstance(a, (Dataset, Variable)):
---> 89         assert a.identical(b), formatting.diff_dataset_repr(a, b, "identical")
     90     else:
     91         raise TypeError("{} not supported by assertion comparison".format(type(a)))

AssertionError: Left and right Dataset objects are not identical

Differing coordinates:
L * lat      (lat) float32 75.0 72.5 70.0 67.5 65.0 ... 25.0 22.5 20.0 17.5 15.0
R * lat      (lat) float32 75.0 72.5 70.0 67.5 65.0 ... 25.0 22.5 20.0 17.5 15.0
    standard_name: latitude
    long_name: Latitude
    units: degrees_north
    axis: Y
Differing data variables:
L   air      (time, lat, lon) float32 241.2 242.5 243.5 ... 296.49 296.19 295.69
R   air      (time, lat, lon) float32 241.2 242.5 243.5 ... 296.49 296.19 295.69
    long_name: 4xDaily Air temperature at sigma level 995
    units: degK
    precision: 2
    GRIB_id: 11
    GRIB_name: TMP
    var_desc: Air temperature
    dataset: NMC Reanalysis
    level_desc: Surface
    statistic: Individual Obs
    parent_stat: Other
    actual_range: [185.16 322.1 ]
```


Discussion:
jukent: This PR passes **kwargs into _coarsen_reshape and changes `_replace` to `copy` in the fx return of coarsen.
pep8speaks: Hello @jukent! Thanks for updating this PR. We checked the lines you've touched for [PEP 8](https://www.python.org/dev/peps/pep-0008) issues, and found:



* In the file [`xarray/tests/test_dataset.py`](https://github.com/pydata/xarray/blob/6f100320238c4785a8eb9d9ad8d0170b01e97ae2/xarray/tests/test_dataset.py):

> [Line 5903:41](https://github.com/pydata/xarray/blob/6f100320238c4785a8eb9d9ad8d0170b01e97ae2/xarray/tests/test_dataset.py#L5903): [E261](https://duckduckgo.com/?q=pep8%20E261) at least two spaces before inline comment


max-sixty: @jukent looks like black may be causing a couple of issues. I'm pushing a fix. You might want to run `python3 -m pip install --upgrade black` to get the latest version of `black` — it recently changed version
max-sixty: (you should `git pull` from your branch, which is now updated with the fix I pushed — let me know any issues)
dcherian: Thanks @jukent This is a nice fix to a complicated part of the code base!

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7265bbac..16165288 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,7 +6,7 @@ repos:
     hooks:
       - id: isort
   # https://github.com/python/black#version-control-integration
-  - repo: https://github.com/python/black
+  - repo: https://github.com/psf/black
     rev: 20.8b1
     hooks:
       - id: black
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 316254a9..b948679b 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -77,6 +77,7 @@ Bug fixes
   and :py:meth:`DataArray.str.wrap` (:issue:`4334`). By `Mathias Hauser <https://github.com/mathause>`_.
 - Fixed overflow issue causing incorrect results in computing means of :py:class:`cftime.datetime`
   arrays (:issue:`4341`). By `Spencer Clark <https://github.com/spencerkclark>`_.
+- Fixed :py:meth:`Dataset.coarsen`, :py:meth:`DataArray.coarsen` dropping attributes on original object (:issue:`4120`, :pull:`4360`). by `Julia Kent <https://github.com/jukent>`_.
 - fix the signature of the plot methods. (:pull:`4359`) By `Justus Magin <https://github.com/keewis>`_.
 - Fix :py:func:`xarray.apply_ufunc` with ``vectorize=True`` and ``exclude_dims`` (:issue:`3890`).
   By `Mathias Hauser <https://github.com/mathause>`_.
diff --git a/xarray/core/rolling.py b/xarray/core/rolling.py
index 0c4614e0..3d7be955 100644
--- a/xarray/core/rolling.py
+++ b/xarray/core/rolling.py
@@ -690,7 +690,7 @@ class DataArrayCoarsen(Coarsen):
             from .dataarray import DataArray
 
             reduced = self.obj.variable.coarsen(
-                self.windows, func, self.boundary, self.side, **kwargs
+                self.windows, func, self.boundary, self.side, self.keep_attrs, **kwargs
             )
             coords = {}
             for c, v in self.obj.coords.items():
@@ -703,6 +703,7 @@ class DataArrayCoarsen(Coarsen):
                             self.coord_func[c],
                             self.boundary,
                             self.side,
+                            self.keep_attrs,
                             **kwargs,
                         )
                     else:
diff --git a/xarray/core/variable.py b/xarray/core/variable.py
index ce6df528..075d7904 100644
--- a/xarray/core/variable.py
+++ b/xarray/core/variable.py
@@ -1993,7 +1993,9 @@ class Variable(
             ),
         )
 
-    def coarsen(self, windows, func, boundary="exact", side="left", **kwargs):
+    def coarsen(
+        self, windows, func, boundary="exact", side="left", keep_attrs=None, **kwargs
+    ):
         """
         Apply reduction function.
         """
@@ -2001,13 +2003,22 @@ class Variable(
         if not windows:
             return self.copy()
 
+        if keep_attrs is None:
+            keep_attrs = _get_keep_attrs(default=False)
+
+        if keep_attrs:
+            _attrs = self.attrs
+        else:
+            _attrs = None
+
         reshaped, axes = self._coarsen_reshape(windows, boundary, side)
         if isinstance(func, str):
             name = func
             func = getattr(duck_array_ops, name, None)
             if func is None:
                 raise NameError(f"{name} is not a valid method.")
-        return self._replace(data=func(reshaped, axis=axes, **kwargs))
+
+        return self._replace(data=func(reshaped, axis=axes, **kwargs), attrs=_attrs)
 
     def _coarsen_reshape(self, windows, boundary, side):
         """
@@ -2072,9 +2083,6 @@ class Variable(
             else:
                 shape.append(variable.shape[i])
 
-        keep_attrs = _get_keep_attrs(default=False)
-        variable.attrs = variable._attrs if keep_attrs else {}
-
         return variable.data.reshape(shape), tuple(axes)
 
     @property


Test patch:
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index 8fd56446..a22ed58c 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -6186,6 +6186,35 @@ def test_isin(da):
     assert_equal(result, expected)
 
 
+def test_coarsen_keep_attrs():
+    _attrs = {"units": "test", "long_name": "testing"}
+
+    da = xr.DataArray(
+        np.linspace(0, 364, num=364),
+        dims="time",
+        coords={"time": pd.date_range("15/12/1999", periods=364)},
+        attrs=_attrs,
+    )
+
+    da2 = da.copy(deep=True)
+
+    # Test dropped attrs
+    dat = da.coarsen(time=3, boundary="trim").mean()
+    assert dat.attrs == {}
+
+    # Test kept attrs using dataset keyword
+    dat = da.coarsen(time=3, boundary="trim", keep_attrs=True).mean()
+    assert dat.attrs == _attrs
+
+    # Test kept attrs using global option
+    with xr.set_options(keep_attrs=True):
+        dat = da.coarsen(time=3, boundary="trim").mean()
+    assert dat.attrs == _attrs
+
+    # Test kept attrs in original object
+    xr.testing.assert_identical(da, da2)
+
+
 @pytest.mark.filterwarnings("error:Mean of empty slice")
 @pytest.mark.parametrize("da", (1, 2), indirect=True)
 def test_rolling_iter(da):
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 16b1cc33..40e2bdfc 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -5956,6 +5956,8 @@ def test_coarsen_keep_attrs():
         attrs=_attrs,
     )
 
+    ds2 = ds.copy(deep=True)
+
     # Test dropped attrs
     dat = ds.coarsen(coord=5).mean()
     assert dat.attrs == {}
@@ -5969,6 +5971,9 @@ def test_coarsen_keep_attrs():
         dat = ds.coarsen(coord=5).mean()
     assert dat.attrs == _attrs
 
+    # Test kept attrs in original object
+    xr.testing.assert_identical(ds, ds2)
+
 
 def test_rolling_keep_attrs():
     _attrs = {"units": "test", "long_name": "testing"}
diff --git a/xarray/tests/test_variable.py b/xarray/tests/test_variable.py
index 169105bb..efebe09e 100644
--- a/xarray/tests/test_variable.py
+++ b/xarray/tests/test_variable.py
@@ -1949,7 +1949,10 @@ class TestVariable(VariableSubclassobjects):
         # Test kept attrs
         with set_options(keep_attrs=True):
             new = Variable(["coord"], np.linspace(1, 10, 100), attrs=_attrs).coarsen(
-                windows={"coord": 1}, func=test_func, boundary="exact", side="left"
+                windows={"coord": 1},
+                func=test_func,
+                boundary="exact",
+                side="left",
             )
         assert new.attrs == _attrs