Repository: pydata/xarray

Issue:
.coarsen() method for the xarray.Dataset removes its attributes.
Hello,

I am not sure if this is a bug or a feature but when one calls the xarray.coarsen() on a dataset, then the attributes get removed. 


**Dataset Example**
```python
import xarray as xr
import numpy as np

var1 = np.linspace(10, 15, 100)
var2 = np.linspace(5, 10, 100)
coords = np.linspace(1, 10, 100)

dat = xr.Dataset(
    data_vars={'var1': ('coord', var1), 'var2': ('coord', var2)}, 
    coords={'coord': coords}
)
dat.attrs['model_id'] = 'model1'

# coarsen dataset
dat = dat.coarsen(coord=5).mean()

# print dataset
dat
```

#### Actual Output

```python
<xarray.Dataset>
Dimensions:  (coord: 20)
Coordinates:
  * coord    (coord) float64 1.182 1.636 2.091 2.545 ... 8.455 8.909 9.364 9.818
Data variables:
    var1     (coord) float64 10.1 10.35 10.61 10.86 ... 14.14 14.39 14.65 14.9
    var2     (coord) float64 5.101 5.354 5.606 5.859 ... 9.141 9.394 9.646 9.899
```


#### Expected Output

```python
<xarray.Dataset>
Dimensions:  (coord: 20)
Coordinates:
  * coord    (coord) float64 1.182 1.636 2.091 2.545 ... 8.455 8.909 9.364 9.818
Data variables:
    var1     (coord) float64 10.1 10.35 10.61 10.86 ... 14.14 14.39 14.65 14.9
    var2     (coord) float64 5.101 5.354 5.606 5.859 ... 9.141 9.394 9.646 9.899
Attributes:
    model_id:  1
```

#### Problem Description

I believe the attributes should stay within the xarray.Dataset no matter what the operations that are done on it. Obviously maybe for some operations an entry like model_id could change because it's no longer the model. But I believe that should be left up to the user. Perhaps a warning in the docs might be sufficient. The behaviour isn't consistent with the xarray.coarsen() function on the xarray.DataArray example where the attributes remain the same (see details below).

**DataArray Example**
<details>

```python
data = np.random.rand(50, 3)
locs = ['IA', 'IL', 'IN']
times = pd.date_range('2000-01-01', periods=50)

foo = xr.DataArray(data, coords=[times, locs], dims=['time', 'space'])
foo.attrs['data_id'] = 'data1'
foo
```

#### Expected/Actual Output

```python
<xarray.DataArray (time: 10, space: 3)>
array([[0.3537571 , 0.50698482, 0.35923528],
       [0.62127828, 0.41852822, 0.5617278 ],
       [0.38669858, 0.60446037, 0.45699182],
       [0.41538186, 0.81251298, 0.3919821 ],
       [0.67914214, 0.45866817, 0.58625095],
       [0.63560785, 0.53796635, 0.48231731],
       [0.60802724, 0.54003065, 0.38456255],
       [0.46492592, 0.78542293, 0.50788668],
       [0.53757801, 0.56765902, 0.52288412],
       [0.51085502, 0.51448292, 0.67426125]])
Coordinates:
  * time     (time) datetime64[ns] 2000-01-03 2000-01-08 ... 2000-02-17
  * space    (space) <U2 'IA' 'IL' 'IN'
Attributes:
    data_id:  data1
```

</details>




#### Output of ``xr.show_versions()``
<details>
INSTALLED VERSIONS
------------------
commit: None
python: 3.7.4 (default, Aug 13 2019, 20:35:49) 
[GCC 7.3.0]
python-bits: 64
OS: Linux
OS-release: 3.10.0-327.el7.x86_64
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8
LOCALE: None.None
libhdf5: 1.10.4
libnetcdf: 4.6.1

xarray: 0.13.0
pandas: 0.25.1
numpy: 1.17.2
scipy: 1.3.1
netCDF4: 1.4.2
pydap: None
h5netcdf: None
h5py: 2.9.0
Nio: None
zarr: 2.3.2
cftime: 1.0.3.4
nc_time_axis: None
PseudoNetCDF: None
rasterio: 1.0.21
cfgrib: None
iris: None
bottleneck: None
dask: 2.4.0
distributed: None
matplotlib: 3.1.1
cartopy: 0.17.0
seaborn: None
numbagg: None
setuptools: 41.2.0
pip: 19.2.3
conda: None
pytest: None
IPython: 7.8.0
sphinx: None
</details>


Discussion:
max-sixty: Thanks for the PR and tests @amcnicho . Are you up for adding the code to pass through `keep_attrs`?
amcnicho: I added code to pass the keyword through and handle it in the constructor of the `Coarsen` classes, and also `Rolling` because that seemed to be necessary as well. Now the test_dataset version of test_coarsen_keep_attrs fails due to a TypeError in the function `nanmean()`. The test_variable version fails the same way as before. A run of the whole test_dataset suite shows all the rest are running as they are on master.

This function `nanmean()` is a child of `duck_array_ops._create_nan_agg_method()` which seems like it should be happily passing along an arbitrary list of kwargs. The statement in the docstring 
> None of these functions should accept or return xarray objects.

makes me suspicious that this keep_attrs keyword shouldn't be reaching this section of the code though. Any guidance would be appreciated.
max-sixty: Recent commits look good @amcnicho !
amcnicho: `DatasetRolling.reduce` passes along **kwargs. `DatasetRolling.construct` does not. Maybe it needs to?
max-sixty: (sorry, I didn't see your message before my message prior)

Do you know what's calling `nanmean` with `keep_attrs`? I think should be handling `keep_attrs` before we get to those numerical functions on variables.
amcnicho: It appears to be the mean function operating on the coarsen object [xarray/tests/test_dataset.py#l.5676](https://github.com/pydata/xarray/pull/3801/commits/af7bd10fa22d126b03639dff3c1e3ddd1a1ddef3#diff-f82411dbe6aa53e3b6a5d9c2b601094cR5676). The `Rolling` class uses `_reduce_method()` to check if the calling function is present in duck_array_ops, presumably to enable extensibility in line with NEP22.

FWIW the dataset object returned by that call in the test looks like this:
```
values = array([[10.        , 10.05050505, 10.1010101 , 10.15151515, 10.2020202 ],
       [10.25252525, 10.3030303 , 10.3535353...96 , 14.64646465, 14.6969697 , 14.74747475],
       [14.7979798 , 14.84848485, 14.8989899 , 14.94949495, 15.        ]])
axis = (1,), skipna = None, kwargs = {'keep_attrs': True}, func = <function nanmean at 0x7f044d715268>, nanname = 'nanmean'
```
```
max-sixty: Right, yes, this does get a bit nested... Appreciate you working through it.

At first glance, `_reduce_method` works differently in `Rolling` & `Coarsen`. You can consider leaving `Rolling` and just focusing on `Coarsen`.

I think you want to catch the kwarg [here](https://github.com/pydata/xarray/blob/master/xarray/core/rolling.py#L617) and use it to decide whether to include the attrs [here](https://github.com/pydata/xarray/blob/master/xarray/core/rolling.py#L647). 
It should _not_ be a kwarg into [`wrapped_func`](https://github.com/pydata/xarray/blob/master/xarray/core/rolling.py#L626).

Does that make sense?
max-sixty: ...but then potentially it does conflict with the existing use of `_reduce_method`. I can have more of a look tonight. 

CC @fujiisoup if you're more familiar with this code. And @dcherian has done some work on it.
max-sixty: @amcnicho 

- Your current `Rolling` implementation looks good. Does that work?
- For `Coarsen`, adding `keep_attrs` to the [constructor](https://github.com/pydata/xarray/pull/3801/files#diff-ed1ee8c19aef738886f1009d3008a4b1R537) is right, and then it needs to be assigned it to the object
- Unlike the `Rolling` implementation, you can add the logic for keeping attrs _within_ the `wrapped_func` (i.e. [here](https://github.com/pydata/xarray/pull/3801/files#diff-ed1ee8c19aef738886f1009d3008a4b1R628) ), by branching on the value of `self.keep_attrs`; `self` there is the `Coarsen` object. 

Does that make sense?
amcnicho: > Your current Rolling implementation looks good. Does that work?

I couldn't find a test that checks attribute persistence across rolling window operations, but when I tested it interactively e.g.

```
dat = ds.rolling({'coord':1}, keep_attrs=True).mean()
dat = ds.rolling({'coord':1}).mean(keep_attrs=True)
assert dat.attrs == _attrs
```

it still doesn't seem to work. I agree that it is better to focus on `Coarsen` only for now.

> For Coarsen, adding keep_attrs to the constructor is right, and then it needs to be assigned it to the object

Yes, I seem not to have included the assignment, or it got lost along the way. I added it with a new commit. Thank you for noticing that.

> Unlike the Rolling implementation, you can add the logic for keeping attrs within the wrapped_func by branching on the value of self.keep_attrs; self there is the Coarsen object.

I think I see now. `DatasetCoarsen._reduce_method.wrapped_func()` accepts the DatasetCoarsen instance created by the `xr.Dataset.coarsen()` call. I will try to add a conditional inside that method's function to propagate the attributes along with the rest of the reduced object based on the value of the keep_attrs keyword.
pep8speaks: Hello @amcnicho! 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/core/rolling.py`](https://github.com/pydata/xarray/blob/28f705fb8f4d52d71854b5dc2edd893ad78ba8b9/xarray/core/rolling.py):

> [Line 652:32](https://github.com/pydata/xarray/blob/28f705fb8f4d52d71854b5dc2edd893ad78ba8b9/xarray/core/rolling.py#L652): [E712](https://duckduckgo.com/?q=pep8%20E712) comparison to True should be 'if cond is True:' or 'if cond:'
> [Line 653:17](https://github.com/pydata/xarray/blob/28f705fb8f4d52d71854b5dc2edd893ad78ba8b9/xarray/core/rolling.py#L653): [F841](https://duckduckgo.com/?q=pep8%20F841) local variable '_attrs' is assigned to but never used






amcnicho: Sorry, I changed this from a draft to ready for review accidentally, and it doesn't look like there is a way to reverse that operation.

The tests still fail after this latest change, but I think we are converging on a fix. The variable.coarsen method is applied to each of the arrays inside `wrapped_func`, so once that is fixed to recognize the global setting both tests should pass.
max-sixty: That's looking good re coarsen! Do tests pass?
amcnicho: Thank you for the review, I’ll incorporate those changes. 
No, the dataset test still raises `xarray/core/duck_array_ops.py:311: TypeError: nanmean() got an unexpected keyword argument 'keep_attrs'` and the variable test still fails due to assertion error. I think I understand why now though. Will test implementation and report.

amcnicho: The recent changes I made to both the code and the tests result in a pass condition for both the dataset and variable version of test_coarsen_keep_attrs. I think adding the keep_attrs keyword argument to the `Variable.coarsen` method would be a breaking change, so I didn’t do that. Instead I added a check for the global setting inside the hidden method that reduces input Variable objects.

Note that there are at least two failure modes remaining:

- In the Dataset case,  if the keep_attrs keyword is provided via the wrapper function (in this case `mean`) it results in the same `TypeError` as before because the converted functions don't expect the new keyword. I left the failing part of the test commented out because I'm not sure if this is supposed to be a supported usage mode.
- In the Variable case, the default behavior of dropping the attributes is permanent, so the operation is non-commutative if coarsening the same Variable instance (even when creating new objects). This seemed acceptable behavior for manipulating primitives.
amcnicho: > Re the 2nd 'failure mode': IIUC the current tests should still pass, and that's a separate (and correct) case; is that right?

That's right. The original test used test_reduce_keep_attrs as a template. Now, instead of defining one Variable object and passing it to both test conditions, a new Variable is defined and coarsened to check each setting of the global option. 

I will merge master in and add similar tests for `Rolling`.
max-sixty: This looks excellent! 

Could we add the change to `whatsnew.rst`?

Then I think we're ready to merge!
max-sixty: Looks great! Any feedback from others? Otherwise will merge later today

Thanks @amcnicho !

Patch:
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 2cc92c78..089cbbe1 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -39,6 +39,11 @@ New Features
   often means a user is attempting to pass multiple dimensions to group over
   and should instead pass a list.
   By `Maximilian Roos <https://github.com/max-sixty>`_
+- The new ``Dataset._repr_html_`` and ``DataArray._repr_html_`` (introduced
+  in 0.14.1) is now on by default. To disable, use
+  ``xarray.set_options(display_style="text")``.
+  By `Julia Signell <https://github.com/jsignell>`_.
+
 
 Bug fixes
 ~~~~~~~~~
@@ -56,6 +61,11 @@ Bug fixes
 - xarray now respects the over, under and bad colors if set on a provided colormap.
   (:issue:`3590`, :pull:`3601`)
   By `johnomotani <https://github.com/johnomotani>`_.
+- :py:func:`coarsen` now respects ``xr.set_options(keep_attrs=True)``
+  to preserve attributes. :py:meth:`Dataset.coarsen` accepts a keyword
+  argument ``keep_attrs`` to change this setting. (:issue:`3376`,
+  :pull:`3801`) By `Andrew Thomas <https://github.com/amcnicho>`_.
+  
 - Fix :py:meth:`xarray.core.dataset.Dataset.to_zarr` when using `append_dim` and `group`
   simultaneously. (:issue:`3170`). By `Matthias Meyer <https://github.com/niowniow>`_.
 
diff --git a/xarray/core/common.py b/xarray/core/common.py
index 582ae310..e3739d6d 100644
--- a/xarray/core/common.py
+++ b/xarray/core/common.py
@@ -753,6 +753,7 @@ class DataWithCoords(SupportsArithmetic, AttrAccessMixin):
         dim: Mapping[Hashable, int] = None,
         min_periods: int = None,
         center: bool = False,
+        keep_attrs: bool = None,
         **window_kwargs: int,
     ):
         """
@@ -769,6 +770,10 @@ class DataWithCoords(SupportsArithmetic, AttrAccessMixin):
             setting min_periods equal to the size of the window.
         center : boolean, default False
             Set the labels at the center of the window.
+        keep_attrs : bool, optional
+            If True, the object's attributes (`attrs`) will be copied from
+            the original object to the new one.  If False (default), the new
+            object will be returned without attributes.
         **window_kwargs : optional
             The keyword arguments form of ``dim``.
             One of dim or window_kwargs must be provided.
@@ -810,8 +815,13 @@ class DataWithCoords(SupportsArithmetic, AttrAccessMixin):
         core.rolling.DataArrayRolling
         core.rolling.DatasetRolling
         """
+        if keep_attrs is None:
+            keep_attrs = _get_keep_attrs(default=False)
+
         dim = either_dict_or_kwargs(dim, window_kwargs, "rolling")
-        return self._rolling_cls(self, dim, min_periods=min_periods, center=center)
+        return self._rolling_cls(
+            self, dim, min_periods=min_periods, center=center, keep_attrs=keep_attrs
+        )
 
     def rolling_exp(
         self,
@@ -859,6 +869,7 @@ class DataWithCoords(SupportsArithmetic, AttrAccessMixin):
         boundary: str = "exact",
         side: Union[str, Mapping[Hashable, str]] = "left",
         coord_func: str = "mean",
+        keep_attrs: bool = None,
         **window_kwargs: int,
     ):
         """
@@ -879,8 +890,12 @@ class DataWithCoords(SupportsArithmetic, AttrAccessMixin):
             multiple of the window size. If 'trim', the excess entries are
             dropped. If 'pad', NA will be padded.
         side : 'left' or 'right' or mapping from dimension to 'left' or 'right'
-        coord_func : function (name) that is applied to the coordintes,
+        coord_func : function (name) that is applied to the coordinates,
             or a mapping from coordinate name to function (name).
+        keep_attrs : bool, optional
+            If True, the object's attributes (`attrs`) will be copied from
+            the original object to the new one.  If False (default), the new
+            object will be returned without attributes.
 
         Returns
         -------
@@ -915,9 +930,17 @@ class DataWithCoords(SupportsArithmetic, AttrAccessMixin):
         core.rolling.DataArrayCoarsen
         core.rolling.DatasetCoarsen
         """
+        if keep_attrs is None:
+            keep_attrs = _get_keep_attrs(default=False)
+
         dim = either_dict_or_kwargs(dim, window_kwargs, "coarsen")
         return self._coarsen_cls(
-            self, dim, boundary=boundary, side=side, coord_func=coord_func
+            self,
+            dim,
+            boundary=boundary,
+            side=side,
+            coord_func=coord_func,
+            keep_attrs=keep_attrs,
         )
 
     def resample(
diff --git a/xarray/core/options.py b/xarray/core/options.py
index 72f9ad8e..15d05159 100644
--- a/xarray/core/options.py
+++ b/xarray/core/options.py
@@ -20,7 +20,7 @@ OPTIONS = {
     CMAP_SEQUENTIAL: "viridis",
     CMAP_DIVERGENT: "RdBu_r",
     KEEP_ATTRS: "default",
-    DISPLAY_STYLE: "text",
+    DISPLAY_STYLE: "html",
 }
 
 _JOIN_OPTIONS = frozenset(["inner", "outer", "left", "right", "exact"])
diff --git a/xarray/core/rolling.py b/xarray/core/rolling.py
index ea6d72b2..61178cfb 100644
--- a/xarray/core/rolling.py
+++ b/xarray/core/rolling.py
@@ -7,6 +7,7 @@ import numpy as np
 from . import dtypes, duck_array_ops, utils
 from .dask_array_ops import dask_rolling_wrapper
 from .ops import inject_reduce_methods
+from .options import _get_keep_attrs
 from .pycompat import dask_array_type
 
 try:
@@ -42,10 +43,10 @@ class Rolling:
     DataArray.rolling
     """
 
-    __slots__ = ("obj", "window", "min_periods", "center", "dim")
-    _attributes = ("window", "min_periods", "center", "dim")
+    __slots__ = ("obj", "window", "min_periods", "center", "dim", "keep_attrs")
+    _attributes = ("window", "min_periods", "center", "dim", "keep_attrs")
 
-    def __init__(self, obj, windows, min_periods=None, center=False):
+    def __init__(self, obj, windows, min_periods=None, center=False, keep_attrs=None):
         """
         Moving window object.
 
@@ -65,6 +66,10 @@ class Rolling:
             setting min_periods equal to the size of the window.
         center : boolean, default False
             Set the labels at the center of the window.
+        keep_attrs : bool, optional
+            If True, the object's attributes (`attrs`) will be copied from
+            the original object to the new one.  If False (default), the new
+            object will be returned without attributes.
 
         Returns
         -------
@@ -89,6 +94,10 @@ class Rolling:
         self.center = center
         self.dim = dim
 
+        if keep_attrs is None:
+            keep_attrs = _get_keep_attrs(default=False)
+        self.keep_attrs = keep_attrs
+
     @property
     def _min_periods(self):
         return self.min_periods if self.min_periods is not None else self.window
@@ -143,7 +152,7 @@ class Rolling:
 class DataArrayRolling(Rolling):
     __slots__ = ("window_labels",)
 
-    def __init__(self, obj, windows, min_periods=None, center=False):
+    def __init__(self, obj, windows, min_periods=None, center=False, keep_attrs=None):
         """
         Moving window object for DataArray.
         You should use DataArray.rolling() method to construct this object
@@ -165,6 +174,10 @@ class DataArrayRolling(Rolling):
             setting min_periods equal to the size of the window.
         center : boolean, default False
             Set the labels at the center of the window.
+        keep_attrs : bool, optional
+            If True, the object's attributes (`attrs`) will be copied from
+            the original object to the new one.  If False (default), the new
+            object will be returned without attributes.
 
         Returns
         -------
@@ -177,7 +190,11 @@ class DataArrayRolling(Rolling):
         Dataset.rolling
         Dataset.groupby
         """
-        super().__init__(obj, windows, min_periods=min_periods, center=center)
+        if keep_attrs is None:
+            keep_attrs = _get_keep_attrs(default=False)
+        super().__init__(
+            obj, windows, min_periods=min_periods, center=center, keep_attrs=keep_attrs
+        )
 
         self.window_labels = self.obj[self.dim]
 
@@ -374,7 +391,7 @@ class DataArrayRolling(Rolling):
 class DatasetRolling(Rolling):
     __slots__ = ("rollings",)
 
-    def __init__(self, obj, windows, min_periods=None, center=False):
+    def __init__(self, obj, windows, min_periods=None, center=False, keep_attrs=None):
         """
         Moving window object for Dataset.
         You should use Dataset.rolling() method to construct this object
@@ -396,6 +413,10 @@ class DatasetRolling(Rolling):
             setting min_periods equal to the size of the window.
         center : boolean, default False
             Set the labels at the center of the window.
+        keep_attrs : bool, optional
+            If True, the object's attributes (`attrs`) will be copied from
+            the original object to the new one.  If False (default), the new
+            object will be returned without attributes.
 
         Returns
         -------
@@ -408,7 +429,7 @@ class DatasetRolling(Rolling):
         Dataset.groupby
         DataArray.groupby
         """
-        super().__init__(obj, windows, min_periods, center)
+        super().__init__(obj, windows, min_periods, center, keep_attrs)
         if self.dim not in self.obj.dims:
             raise KeyError(self.dim)
         # Keep each Rolling object as a dictionary
@@ -416,7 +437,9 @@ class DatasetRolling(Rolling):
         for key, da in self.obj.data_vars.items():
             # keeps rollings only for the dataset depending on slf.dim
             if self.dim in da.dims:
-                self.rollings[key] = DataArrayRolling(da, windows, min_periods, center)
+                self.rollings[key] = DataArrayRolling(
+                    da, windows, min_periods, center, keep_attrs
+                )
 
     def _dataset_implementation(self, func, **kwargs):
         from .dataset import Dataset
@@ -427,7 +450,8 @@ class DatasetRolling(Rolling):
                 reduced[key] = func(self.rollings[key], **kwargs)
             else:
                 reduced[key] = self.obj[key]
-        return Dataset(reduced, coords=self.obj.coords)
+        attrs = self.obj.attrs if self.keep_attrs else {}
+        return Dataset(reduced, coords=self.obj.coords, attrs=attrs)
 
     def reduce(self, func, **kwargs):
         """Reduce the items in this group by applying `func` along some
@@ -466,7 +490,7 @@ class DatasetRolling(Rolling):
             **kwargs,
         )
 
-    def construct(self, window_dim, stride=1, fill_value=dtypes.NA):
+    def construct(self, window_dim, stride=1, fill_value=dtypes.NA, keep_attrs=None):
         """
         Convert this rolling object to xr.Dataset,
         where the window dimension is stacked as a new dimension
@@ -487,6 +511,9 @@ class DatasetRolling(Rolling):
 
         from .dataset import Dataset
 
+        if keep_attrs is None:
+            keep_attrs = _get_keep_attrs(default=True)
+
         dataset = {}
         for key, da in self.obj.data_vars.items():
             if self.dim in da.dims:
@@ -509,10 +536,18 @@ class Coarsen:
     DataArray.coarsen
     """
 
-    __slots__ = ("obj", "boundary", "coord_func", "windows", "side", "trim_excess")
+    __slots__ = (
+        "obj",
+        "boundary",
+        "coord_func",
+        "windows",
+        "side",
+        "trim_excess",
+        "keep_attrs",
+    )
     _attributes = ("windows", "side", "trim_excess")
 
-    def __init__(self, obj, windows, boundary, side, coord_func):
+    def __init__(self, obj, windows, boundary, side, coord_func, keep_attrs):
         """
         Moving window object.
 
@@ -541,6 +576,7 @@ class Coarsen:
         self.windows = windows
         self.side = side
         self.boundary = boundary
+        self.keep_attrs = keep_attrs
 
         absent_dims = [dim for dim in windows.keys() if dim not in self.obj.dims]
         if absent_dims:
@@ -626,6 +662,11 @@ class DatasetCoarsen(Coarsen):
         def wrapped_func(self, **kwargs):
             from .dataset import Dataset
 
+            if self.keep_attrs:
+                attrs = self.obj.attrs
+            else:
+                attrs = {}
+
             reduced = {}
             for key, da in self.obj.data_vars.items():
                 reduced[key] = da.variable.coarsen(
@@ -644,7 +685,7 @@ class DatasetCoarsen(Coarsen):
                     )
                 else:
                     coords[c] = v.variable
-            return Dataset(reduced, coords=coords)
+            return Dataset(reduced, coords=coords, attrs=attrs)
 
         return wrapped_func
 
diff --git a/xarray/core/variable.py b/xarray/core/variable.py
index daa86781..62f9fde6 100644
--- a/xarray/core/variable.py
+++ b/xarray/core/variable.py
@@ -1949,6 +1949,9 @@ 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_dataset.py b/xarray/tests/test_dataset.py
index 5e254c37..7bcf9379 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -5664,6 +5664,62 @@ def test_coarsen_coords_cftime():
     np.testing.assert_array_equal(actual.time, expected_times)
 
 
+def test_coarsen_keep_attrs():
+    _attrs = {"units": "test", "long_name": "testing"}
+
+    var1 = np.linspace(10, 15, 100)
+    var2 = np.linspace(5, 10, 100)
+    coords = np.linspace(1, 10, 100)
+
+    ds = Dataset(
+        data_vars={"var1": ("coord", var1), "var2": ("coord", var2)},
+        coords={"coord": coords},
+        attrs=_attrs,
+    )
+
+    # Test dropped attrs
+    dat = ds.coarsen(coord=5).mean()
+    assert dat.attrs == {}
+
+    # Test kept attrs using dataset keyword
+    dat = ds.coarsen(coord=5, keep_attrs=True).mean()
+    assert dat.attrs == _attrs
+
+    # Test kept attrs using global option
+    with set_options(keep_attrs=True):
+        dat = ds.coarsen(coord=5).mean()
+    assert dat.attrs == _attrs
+
+
+def test_rolling_keep_attrs():
+    _attrs = {"units": "test", "long_name": "testing"}
+
+    var1 = np.linspace(10, 15, 100)
+    var2 = np.linspace(5, 10, 100)
+    coords = np.linspace(1, 10, 100)
+
+    ds = Dataset(
+        data_vars={"var1": ("coord", var1), "var2": ("coord", var2)},
+        coords={"coord": coords},
+        attrs=_attrs,
+    )
+
+    # Test dropped attrs
+    dat = ds.rolling(dim={"coord": 5}, min_periods=None, center=False).mean()
+    assert dat.attrs == {}
+
+    # Test kept attrs using dataset keyword
+    dat = ds.rolling(
+        dim={"coord": 5}, min_periods=None, center=False, keep_attrs=True
+    ).mean()
+    assert dat.attrs == _attrs
+
+    # Test kept attrs using global option
+    with set_options(keep_attrs=True):
+        dat = ds.rolling(dim={"coord": 5}, min_periods=None, center=False).mean()
+    assert dat.attrs == _attrs
+
+
 def test_rolling_properties(ds):
     # catching invalid args
     with pytest.raises(ValueError, match="exactly one dim/window should"):
diff --git a/xarray/tests/test_options.py b/xarray/tests/test_options.py
index f155acbf..19f74476 100644
--- a/xarray/tests/test_options.py
+++ b/xarray/tests/test_options.py
@@ -68,12 +68,12 @@ def test_nested_options():
 
 
 def test_display_style():
-    original = "text"
+    original = "html"
     assert OPTIONS["display_style"] == original
     with pytest.raises(ValueError):
         xarray.set_options(display_style="invalid_str")
-    with xarray.set_options(display_style="html"):
-        assert OPTIONS["display_style"] == "html"
+    with xarray.set_options(display_style="text"):
+        assert OPTIONS["display_style"] == "text"
     assert OPTIONS["display_style"] == original
 
 
@@ -177,10 +177,11 @@ class TestAttrRetention:
 
     def test_display_style_text(self):
         ds = create_test_dataset_attrs()
-        text = ds._repr_html_()
-        assert text.startswith("<pre>")
-        assert "&#x27;nested&#x27;" in text
-        assert "&lt;xarray.Dataset&gt;" in text
+        with xarray.set_options(display_style="text"):
+            text = ds._repr_html_()
+            assert text.startswith("<pre>")
+            assert "&#x27;nested&#x27;" in text
+            assert "&lt;xarray.Dataset&gt;" in text
 
     def test_display_style_html(self):
         ds = create_test_dataset_attrs()
@@ -191,9 +192,10 @@ class TestAttrRetention:
 
     def test_display_dataarray_style_text(self):
         da = create_test_dataarray_attrs()
-        text = da._repr_html_()
-        assert text.startswith("<pre>")
-        assert "&lt;xarray.DataArray &#x27;var1&#x27;" in text
+        with xarray.set_options(display_style="text"):
+            text = da._repr_html_()
+            assert text.startswith("<pre>")
+            assert "&lt;xarray.DataArray &#x27;var1&#x27;" in text
 
     def test_display_dataarray_style_html(self):
         da = create_test_dataarray_attrs()
diff --git a/xarray/tests/test_variable.py b/xarray/tests/test_variable.py
index 62fde920..c86ecd01 100644
--- a/xarray/tests/test_variable.py
+++ b/xarray/tests/test_variable.py
@@ -9,7 +9,7 @@ import pytest
 import pytz
 
 from xarray import Coordinate, Dataset, IndexVariable, Variable, set_options
-from xarray.core import dtypes, indexing
+from xarray.core import dtypes, duck_array_ops, indexing
 from xarray.core.common import full_like, ones_like, zeros_like
 from xarray.core.indexing import (
     BasicIndexer,
@@ -1879,6 +1879,26 @@ class TestVariable(VariableSubclassobjects):
         expected = self.cls(("x", "y"), [[10, 18], [42, 35]])
         assert_equal(actual, expected)
 
+    # perhaps @pytest.mark.parametrize("operation", [f for f in duck_array_ops])
+    def test_coarsen_keep_attrs(self, operation="mean"):
+        _attrs = {"units": "test", "long_name": "testing"}
+
+        test_func = getattr(duck_array_ops, operation, None)
+
+        # Test dropped attrs
+        with set_options(keep_attrs=False):
+            new = Variable(["coord"], np.linspace(1, 10, 100), attrs=_attrs).coarsen(
+                windows={"coord": 1}, func=test_func, boundary="exact", side="left"
+            )
+        assert new.attrs == {}
+
+        # 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"
+            )
+        assert new.attrs == _attrs
+
 
 @requires_dask
 class TestVariableWithDask(VariableSubclassobjects):