Repository: pandas-dev/pandas (python)

Issue:
BUG: binary operations don't propogate attrs depending on order with Series and/or DataFrame/Series
### Pandas version checks

- [X] I have checked that this issue has not already been reported.

- [X] I have confirmed this bug exists on the [latest version](https://pandas.pydata.org/docs/whatsnew/index.html) of pandas.

- [X] I have confirmed this bug exists on the [main branch](https://pandas.pydata.org/docs/dev/getting_started/install.html#installing-the-development-version-of-pandas) of pandas.


### Reproducible Example

```python
In [1]: ser1 = pd.Series(range(1))

In [2]: ser2 = pd.Series(range(1))

In [3]: ser1.attrs = {1:2}

In [4]: val = ser2 + ser1

In [5]: val.attrs
Out[5]: {}
```


### Issue Description

`val.attrs` should be `{1:2}`

The relevant tests are xfailed in `test_finalize.py::test_binary`

### Expected Behavior

`val.attrs` should be `{1:2}`

### Installed Versions

<details>

Replace this line with the output of pd.show_versions()

</details>


Pull request:
Fix binary operations on attrs for Series and DataFrame
- [ ] closes #51607
- [Test](https://github.com/pandas-dev/pandas/blob/main/pandas/tests/series/test_api.py)
- [Test](https://github.com/pandas-dev/pandas/blob/main/pandas/tests/frame/test_api.py)


Patch:
diff --git a/pandas/core/base.py b/pandas/core/base.py
index ea7e99f6f1..6cc28d4e46 100644
--- a/pandas/core/base.py
+++ b/pandas/core/base.py
@@ -1480,9 +1480,9 @@ class IndexOpsMixin(OpsMixin):
         with np.errstate(all="ignore"):
             result = ops.arithmetic_op(lvalues, rvalues, op)
 
-        return self._construct_result(result, name=res_name)
+        return self._construct_result(result, name=res_name, other=other)
 
-    def _construct_result(self, result, name):
+    def _construct_result(self, result, name, other):
         """
         Construct an appropriately-wrapped result from the ArrayLike result
         of an arithmetic-like operation.
diff --git a/pandas/core/frame.py b/pandas/core/frame.py
index dea246bcd1..6158e19737 100644
--- a/pandas/core/frame.py
+++ b/pandas/core/frame.py
@@ -7879,7 +7879,7 @@ class DataFrame(NDFrame, OpsMixin):
 
         # See GH#4537 for discussion of scalar op behavior
         new_data = self._dispatch_frame_op(other, op, axis=axis)
-        return self._construct_result(new_data)
+        return self._construct_result(new_data, other=other)
 
     def _arith_method(self, other, op):
         if self._should_reindex_frame_op(other, op, 1, None, None):
@@ -7892,7 +7892,7 @@ class DataFrame(NDFrame, OpsMixin):
 
         with np.errstate(all="ignore"):
             new_data = self._dispatch_frame_op(other, op, axis=axis)
-        return self._construct_result(new_data)
+        return self._construct_result(new_data, other=other)
 
     _logical_method = _arith_method
 
@@ -8088,8 +8088,7 @@ class DataFrame(NDFrame, OpsMixin):
 
         Parameters
         ----------
-        left : DataFrame
-        right : Any
+        other : Any
         axis : int
         flex : bool or None, default False
             Whether this is a flex op, in which case we reindex.
@@ -8208,7 +8207,6 @@ class DataFrame(NDFrame, OpsMixin):
                 level=level,
             )
             right = left._maybe_align_series_as_frame(right, axis)
-
         return left, right
 
     def _maybe_align_series_as_frame(self, series: Series, axis: AxisInt):
@@ -8237,7 +8235,7 @@ class DataFrame(NDFrame, OpsMixin):
             index=self.index,
             columns=self.columns,
             dtype=rvalues.dtype,
-        )
+        ).__finalize__(series)
 
     def _flex_arith_method(
         self, other, op, *, axis: Axis = "columns", level=None, fill_value=None
@@ -8269,9 +8267,9 @@ class DataFrame(NDFrame, OpsMixin):
 
                 new_data = self._dispatch_frame_op(other, op)
 
-        return self._construct_result(new_data)
+        return self._construct_result(new_data, other=other)
 
-    def _construct_result(self, result) -> DataFrame:
+    def _construct_result(self, result, other) -> DataFrame:
         """
         Wrap the result of an arithmetic, comparison, or logical operation.
 
@@ -8288,6 +8286,7 @@ class DataFrame(NDFrame, OpsMixin):
         #  non-unique columns case
         out.columns = self.columns
         out.index = self.index
+        out = out.__finalize__(other)
         return out
 
     def __divmod__(self, other) -> tuple[DataFrame, DataFrame]:
@@ -8308,7 +8307,7 @@ class DataFrame(NDFrame, OpsMixin):
         self, other = self._align_for_op(other, axis, flex=True, level=level)
 
         new_data = self._dispatch_frame_op(other, op, axis=axis)
-        return self._construct_result(new_data)
+        return self._construct_result(new_data, other=other)
 
     @Appender(ops.make_flex_doc("eq", "dataframe"))
     def eq(self, other, axis: Axis = "columns", level=None) -> DataFrame:
diff --git a/pandas/core/generic.py b/pandas/core/generic.py
index 875e890553..6a45ef9325 100644
--- a/pandas/core/generic.py
+++ b/pandas/core/generic.py
@@ -6076,8 +6076,10 @@ class NDFrame(PandasObject, indexing.IndexingMixin):
                 # One could make the deepcopy unconditionally, but a deepcopy
                 # of an empty dict is 50x more expensive than the empty check.
                 self.attrs = deepcopy(other.attrs)
-
-            self.flags.allows_duplicate_labels = other.flags.allows_duplicate_labels
+            self.flags.allows_duplicate_labels = (
+                self.flags.allows_duplicate_labels
+                and other.flags.allows_duplicate_labels
+            )
             # For subclasses using _metadata.
             for name in set(self._metadata) & set(other._metadata):
                 assert isinstance(name, str)
diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py
index 2079ff8fd2..ff38790186 100644
--- a/pandas/core/indexes/base.py
+++ b/pandas/core/indexes/base.py
@@ -7148,10 +7148,10 @@ class Index(IndexOpsMixin, PandasObject):
         rvalues = extract_array(other, extract_numpy=True, extract_range=True)
 
         res_values = ops.logical_op(lvalues, rvalues, op)
-        return self._construct_result(res_values, name=res_name)
+        return self._construct_result(res_values, name=res_name, other=other)
 
     @final
-    def _construct_result(self, result, name):
+    def _construct_result(self, result, name, other):
         if isinstance(result, tuple):
             return (
                 Index(result[0], name=name, dtype=result[0].dtype),
diff --git a/pandas/core/series.py b/pandas/core/series.py
index 258e0100a8..03a2ce85a0 100644
--- a/pandas/core/series.py
+++ b/pandas/core/series.py
@@ -5858,7 +5858,7 @@ class Series(base.IndexOpsMixin, NDFrame):  # type: ignore[misc]
 
         res_values = ops.comparison_op(lvalues, rvalues, op)
 
-        return self._construct_result(res_values, name=res_name)
+        return self._construct_result(res_values, name=res_name, other=other)
 
     def _logical_method(self, other, op):
         res_name = ops.get_op_result_name(self, other)
@@ -5868,7 +5868,7 @@ class Series(base.IndexOpsMixin, NDFrame):  # type: ignore[misc]
         rvalues = extract_array(other, extract_numpy=True, extract_range=True)
 
         res_values = ops.logical_op(lvalues, rvalues, op)
-        return self._construct_result(res_values, name=res_name)
+        return self._construct_result(res_values, name=res_name, other=other)
 
     def _arith_method(self, other, op):
         self, other = self._align_for_op(other)
@@ -5930,11 +5930,15 @@ class Series(base.IndexOpsMixin, NDFrame):  # type: ignore[misc]
             result = func(this_vals, other_vals)
 
         name = ops.get_op_result_name(self, other)
-        out = this._construct_result(result, name)
+
+        out = this._construct_result(result, name, other)
         return cast(Series, out)
 
     def _construct_result(
-        self, result: ArrayLike | tuple[ArrayLike, ArrayLike], name: Hashable
+        self,
+        result: ArrayLike | tuple[ArrayLike, ArrayLike],
+        name: Hashable,
+        other: AnyArrayLike | DataFrame,
     ) -> Series | tuple[Series, Series]:
         """
         Construct an appropriately-labelled Series from the result of an op.
@@ -5943,6 +5947,7 @@ class Series(base.IndexOpsMixin, NDFrame):  # type: ignore[misc]
         ----------
         result : ndarray or ExtensionArray
         name : Label
+        other : Series, DataFrame or array-like
 
         Returns
         -------
@@ -5952,8 +5957,8 @@ class Series(base.IndexOpsMixin, NDFrame):  # type: ignore[misc]
         if isinstance(result, tuple):
             # produced by divmod or rdivmod
 
-            res1 = self._construct_result(result[0], name=name)
-            res2 = self._construct_result(result[1], name=name)
+            res1 = self._construct_result(result[0], name=name, other=other)
+            res2 = self._construct_result(result[1], name=name, other=other)
 
             # GH#33427 assertions to keep mypy happy
             assert isinstance(res1, Series)
@@ -5965,6 +5970,7 @@ class Series(base.IndexOpsMixin, NDFrame):  # type: ignore[misc]
         dtype = getattr(result, "dtype", None)
         out = self._constructor(result, index=self.index, dtype=dtype, copy=False)
         out = out.__finalize__(self)
+        out = out.__finalize__(other)
 
         # Set the result's name after __finalize__ is called because __finalize__
         #  would set it back to self.name
diff --git a/web/pandas/community/ecosystem.md b/web/pandas/community/ecosystem.md
index 74b7c1f488..c6dddd5c2e 100644
--- a/web/pandas/community/ecosystem.md
+++ b/web/pandas/community/ecosystem.md
@@ -146,11 +146,8 @@ Seaborn](https://plot.ly/python/matplotlib-to-plotly-tutorial/) can
 convert figures into interactive web-based plots. Plots can be drawn in
 [IPython Notebooks](https://plot.ly/ipython-notebooks/) , edited with R
 or MATLAB, modified in a GUI, or embedded in apps and dashboards. Plotly
-is free for unlimited sharing, and has
-[cloud](https://plot.ly/product/plans/),
-[offline](https://plot.ly/python/offline/), or
-[on-premise](https://plot.ly/product/enterprise/) accounts for private
-use.
+is free for unlimited sharing, and has cloud, offline, or on-premise
+accounts for private use.
 
 ### [Lux](https://github.com/lux-org/lux)
 


Test patch:
diff --git a/pandas/tests/generic/test_finalize.py b/pandas/tests/generic/test_finalize.py
index a88090b004..4b841b54c4 100644
--- a/pandas/tests/generic/test_finalize.py
+++ b/pandas/tests/generic/test_finalize.py
@@ -427,53 +427,6 @@ def test_binops(request, args, annotate, all_binary_operators):
     if annotate == "right" and isinstance(right, int):
         pytest.skip("right is an int and doesn't support .attrs")
 
-    if not (isinstance(left, int) or isinstance(right, int)) and annotate != "both":
-        if not all_binary_operators.__name__.startswith("r"):
-            if annotate == "right" and isinstance(left, type(right)):
-                request.applymarker(
-                    pytest.mark.xfail(
-                        reason=f"{all_binary_operators} doesn't work when right has "
-                        f"attrs and both are {type(left)}"
-                    )
-                )
-            if not isinstance(left, type(right)):
-                if annotate == "left" and isinstance(left, pd.Series):
-                    request.applymarker(
-                        pytest.mark.xfail(
-                            reason=f"{all_binary_operators} doesn't work when the "
-                            "objects are different Series has attrs"
-                        )
-                    )
-                elif annotate == "right" and isinstance(right, pd.Series):
-                    request.applymarker(
-                        pytest.mark.xfail(
-                            reason=f"{all_binary_operators} doesn't work when the "
-                            "objects are different Series has attrs"
-                        )
-                    )
-        else:
-            if annotate == "left" and isinstance(left, type(right)):
-                request.applymarker(
-                    pytest.mark.xfail(
-                        reason=f"{all_binary_operators} doesn't work when left has "
-                        f"attrs and both are {type(left)}"
-                    )
-                )
-            if not isinstance(left, type(right)):
-                if annotate == "right" and isinstance(right, pd.Series):
-                    request.applymarker(
-                        pytest.mark.xfail(
-                            reason=f"{all_binary_operators} doesn't work when the "
-                            "objects are different Series has attrs"
-                        )
-                    )
-                elif annotate == "left" and isinstance(left, pd.Series):
-                    request.applymarker(
-                        pytest.mark.xfail(
-                            reason=f"{all_binary_operators} doesn't work when the "
-                            "objects are different Series has attrs"
-                        )
-                    )
     if annotate in {"left", "both"} and not isinstance(left, int):
         left.attrs = {"a": 1}
     if annotate in {"right", "both"} and not isinstance(right, int):
@@ -497,6 +450,18 @@ def test_binops(request, args, annotate, all_binary_operators):
     assert result.attrs == {"a": 1}
 
 
+@pytest.mark.parametrize("left", [pd.Series, pd.DataFrame])
+@pytest.mark.parametrize("right", [pd.Series, pd.DataFrame])
+def test_attrs_binary_operations(all_binary_operators, left, right):
+    # GH 51607
+    attrs = {"a": 1}
+    left = left([1])
+    left.attrs = attrs
+    right = right([2])
+    assert all_binary_operators(left, right).attrs == attrs
+    assert all_binary_operators(right, left).attrs == attrs
+
+
 # ----------------------------------------------------------------------------
 # Accessors