Repository: pydata/xarray

Issue:
DataArray().drop_attrs(deep=False) doesn't do anything
[Edit] I didn't see any issue templates because I took the route via opening an issue linked to a line of code, which seems to have failed anyway.

I tried to drop DataArray attributes without dropping the coordinate attributes. For this, I set `deep=False` in the dataarray's method `.drop_attrs()`. This did not work.

## Environment

```
INSTALLED VERSIONS
------------------
commit: None
python: 3.12.3 (main, Jan 17 2025, 18:03:48) [GCC 13.3.0]
python-bits: 64
OS: Linux
OS-release: 6.8.0-52-generic
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.14.2
libnetcdf: 4.9.4-development

xarray: 2025.1.1
pandas: 2.2.3
numpy: 2.2.2
scipy: 1.15.1
netCDF4: 1.7.2
pydap: None
h5netcdf: None
h5py: None
zarr: 3.0.1
cftime: 1.6.4.post1
nc_time_axis: None
iris: None
bottleneck: None
dask: 2025.1.0
distributed: None
matplotlib: 3.10.0
cartopy: 0.24.1
seaborn: None
numbagg: None
fsspec: 2024.12.0
cupy: None
pint: 0.24.4
sparse: None
flox: None
numpy_groupies: None
setuptools: 75.8.0
pip: 25.0
conda: None
pytest: 8.3.4
mypy: None
IPython: 8.31.0
sphinx: None
```

## Minimal example to reproduce

```python
import xarray as xr
a = xr.DataArray(0).assign_attrs({"b": "c"})
a = a.drop_attrs(deep=False)
print(a)
```
results in
```
<xarray.DataArray ()> Size: 8B
array(0)
Attributes:
    b:        c
```
The attributes should have been dropped but they are still present.

--------------
*my initial guess*

[Edit] removed, was wrong, see below

Hints from issue comments:
@welcome: Thanks for opening your first issue here at xarray! Be sure to follow the issue template!
If you have an idea for a solution, we would really welcome a Pull Request with proposed changes.
See the [Contributing Guide](https://docs.xarray.dev/en/latest/contributing.html) for more.
It may take us a while to respond here, but we really value your contribution. Contributors like you help make xarray better.
Thank you!
@j-haacker: I see now that should have opened the issue from the issues tab. If required, I can close this and open a new issue using the template.@j-haacker: My initial guess, that the dataarray attributes survived in a copy of itself, was wrong. I believe the answer is much simpler:

I believe that in https://github.com/pydata/xarray/blob/160cced8981dd0bf88fbd9016b0d693bd9fd7933/xarray/core/dataarray.py#L7579 `_to_temp_dataset()` wraps the variable in a Dataset, consequently `.drop_attrs()` only drops the dataset, but not the dataarray attributes if `deep=False` - see https://github.com/pydata/xarray/blob/160cced8981dd0bf88fbd9016b0d693bd9fd7933/xarray/core/dataset.py#L10931@j-haacker: I will leave it here because I can't think of a satisfying solution.

Allowing the argument `deep` to be a Literal meant to specify a depth level seems odd to me.

Changing Dataset().drop_attrs() to drop variable attributes, even with `deep=False`, would change the behavior in general and should not be done.

Using a new `_to_temp_dataarray()` method seems like an overkill.@mathause: Yes that only drops the `attrs` on the temporary dataset (which don't exist) - so I'd say this is a bug. I think that should be

```python
if deep:
    return self._to_temp_dataset().drop_attrs(deep=deep)._from_temp_dataset()
else:
    return self._replace(attrs={})
```

(using `_to_temp_dataset()` is a common pattern in xarray to implement methods on the DataArray level)

edit: 
- my old suggestion updated in-place
- looks like `DataArray._replace` does not yet take `attrs`@j-haacker: I agree, that maybe it would be well-enough to treat the cases separately. If replace doesn't work, may this would be as good:
```
    if not deep:
        for k in list(self.attrs):
            del self.attrs[k]
        return self
    else:
        return (
            self._to_temp_dataset().drop_attrs(deep=deep).pipe(self._from_temp_dataset)
        )
```

Test it with:
```python
import xarray as xr

def drop_attrs(self, *, deep: bool = True):
    if not deep:
        for k in list(self.attrs):
            del self.attrs[k]
        return self
    else:
        return (
            self._to_temp_dataset().drop_attrs(deep=deep).pipe(self._from_temp_dataset)
        )
    
xr.core.dataarray.DataArray.drop_attrs = drop_attrs

a = xr.DataArray(0).assign_attrs({"b": "c"})
a = a.drop_attrs(deep=False)
print(a)
```

Solution patch:
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index fe63a923..8b74d3cd 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -42,6 +42,9 @@ Bug fixes
 - Use mean of min/max years as offset in calculation of datetime64 mean
   (:issue:`10019`, :pull:`10035`).
   By `Kai Mühlbauer <https://github.com/kmuehlbauer>`_.
+- Fix DataArray().drop_attrs(deep=False) and add support for attrs to
+  DataArray()._replace(). (:issue:`10027`, :pull:`10030`). By `Jan
+  Haacker <https://github.com/j-haacker>`_.
 
 
 Documentation
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index 6c4c17e7..ff2d5198 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import copy
 import datetime
 import warnings
 from collections.abc import (
@@ -522,6 +523,7 @@ class DataArray(
         variable: Variable | None = None,
         coords=None,
         name: Hashable | None | Default = _default,
+        attrs=_default,
         indexes=None,
     ) -> Self:
         if variable is None:
@@ -532,6 +534,11 @@ class DataArray(
             indexes = self._indexes
         if name is _default:
             name = self.name
+        if attrs is _default:
+            attrs = copy.copy(self.attrs)
+        else:
+            variable = variable.copy()
+            variable.attrs = attrs
         return type(self)(variable, coords, name=name, indexes=indexes, fastpath=True)
 
     def _replace_maybe_drop_dims(
@@ -7575,6 +7582,11 @@ class DataArray(
         -------
         DataArray
         """
-        return (
-            self._to_temp_dataset().drop_attrs(deep=deep).pipe(self._from_temp_dataset)
-        )
+        if not deep:
+            return self._replace(attrs={})
+        else:
+            return (
+                self._to_temp_dataset()
+                .drop_attrs(deep=deep)
+                .pipe(self._from_temp_dataset)
+            )


Test patch:
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index 7d1017f1..75d6d919 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -1908,6 +1908,21 @@ class TestDataArray:
             warnings.simplefilter("error")
             da.rename(x="x")
 
+    def test_replace(self) -> None:
+        # Tests the `attrs` replacement and whether it interferes with a
+        # `variable` replacement
+        da = self.mda
+        attrs1 = {"a1": "val1", "a2": 161}
+        x = np.ones((10, 20))
+        v = Variable(["x", "y"], x)
+        assert da._replace(variable=v, attrs=attrs1).attrs == attrs1
+        attrs2 = {"b1": "val2", "b2": 1312}
+        va = Variable(["x", "y"], x, attrs2)
+        # assuming passed `attrs` should prevail
+        assert da._replace(variable=va, attrs=attrs1).attrs == attrs1
+        # assuming `va.attrs` should be adopted
+        assert da._replace(variable=va).attrs == attrs2
+
     def test_init_value(self) -> None:
         expected = DataArray(
             np.full((3, 4), 3), dims=["x", "y"], coords=[range(3), range(4)]
@@ -2991,8 +3006,11 @@ class TestDataArray:
 
     def test_drop_attrs(self) -> None:
         # Mostly tested in test_dataset.py, but adding a very small test here
-        da = DataArray([], attrs=dict(a=1, b=2))
+        coord_ = DataArray([], attrs=dict(d=3, e=4))
+        da = DataArray([], attrs=dict(a=1, b=2)).assign_coords(dict(coord_=coord_))
         assert da.drop_attrs().attrs == {}
+        assert da.drop_attrs().coord_.attrs == {}
+        assert da.drop_attrs(deep=False).coord_.attrs == dict(d=3, e=4)
 
     @pytest.mark.parametrize(
         "func", [lambda x: x.clip(0, 1), lambda x: np.float64(1.0) * x, np.abs, abs]