Repository: pandas-dev/pandas

Issue:
BUG: pandas 2.1.2 changes how `copy` works
### 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
import pandas as pd

class Sub(pd.DataFrame): pass

assert not isinstance(Sub().copy(), Sub)
```


### Issue Description

Since very long ago, calling `.copy()` on a DataFrame subclass returned a DataFrame object. This should be changed in a major release, not a feature release, and definitely not a patch release.

### Expected Behavior

The above assert statement to succeed


### See also

- Probably related: https://github.com/pandas-dev/pandas/issues/55120
- Downstream bug report: https://github.com/scverse/anndata/issues/1210

### Installed Versions

pandas 2.1.2 or 2.2.0.dev0+447


Discussion:
We’re pinning Pandas to !=2.1.2.

Can we rely on Pandas 2.2.0 to fix this?

Patch:
diff --git a/doc/source/whatsnew/v2.1.3.rst b/doc/source/whatsnew/v2.1.3.rst
index 31ab01f171b4a..6413e16afd800 100644
--- a/doc/source/whatsnew/v2.1.3.rst
+++ b/doc/source/whatsnew/v2.1.3.rst
@@ -13,7 +13,7 @@ including other versions of pandas.
 
 Fixed regressions
 ~~~~~~~~~~~~~~~~~
--
+- Fixed infinite recursion from operations that return a new object on some DataFrame subclasses (:issue:`55763`)
 -
 
 .. ---------------------------------------------------------------------------
diff --git a/pandas/core/frame.py b/pandas/core/frame.py
index f37be37f37693..613b6a39841a2 100644
--- a/pandas/core/frame.py
+++ b/pandas/core/frame.py
@@ -648,7 +648,7 @@ def _constructor(self) -> Callable[..., DataFrame]:
     def _constructor_from_mgr(self, mgr, axes):
         if self._constructor is DataFrame:
             # we are pandas.DataFrame (or a subclass that doesn't override _constructor)
-            return self._from_mgr(mgr, axes=axes)
+            return DataFrame._from_mgr(mgr, axes=axes)
         else:
             assert axes is mgr.axes
             return self._constructor(mgr)
diff --git a/pandas/core/series.py b/pandas/core/series.py
index 88d3616423155..368090a37d977 100644
--- a/pandas/core/series.py
+++ b/pandas/core/series.py
@@ -637,7 +637,7 @@ def _constructor(self) -> Callable[..., Series]:
     def _constructor_from_mgr(self, mgr, axes):
         if self._constructor is Series:
             # we are pandas.Series (or a subclass that doesn't override _constructor)
-            ser = self._from_mgr(mgr, axes=axes)
+            ser = Series._from_mgr(mgr, axes=axes)
             ser._name = None  # caller is responsible for setting real name
             return ser
         else:


Test patch:
diff --git a/pandas/tests/frame/test_subclass.py b/pandas/tests/frame/test_subclass.py
index 55bfefaeb53a9..f19e31002c877 100644
--- a/pandas/tests/frame/test_subclass.py
+++ b/pandas/tests/frame/test_subclass.py
@@ -773,3 +773,44 @@ def test_constructor_with_metadata():
     )
     subset = df[["A", "B"]]
     assert isinstance(subset, MySubclassWithMetadata)
+
+
+class SimpleDataFrameSubClass(DataFrame):
+    """A subclass of DataFrame that does not define a constructor."""
+
+
+class SimpleSeriesSubClass(Series):
+    """A subclass of Series that does not define a constructor."""
+
+
+class TestSubclassWithoutConstructor:
+    def test_copy_df(self):
+        expected = DataFrame({"a": [1, 2, 3]})
+        result = SimpleDataFrameSubClass(expected).copy()
+
+        assert (
+            type(result) is DataFrame
+        )  # assert_frame_equal only checks isinstance(lhs, type(rhs))
+        tm.assert_frame_equal(result, expected)
+
+    def test_copy_series(self):
+        expected = Series([1, 2, 3])
+        result = SimpleSeriesSubClass(expected).copy()
+
+        tm.assert_series_equal(result, expected)
+
+    def test_series_to_frame(self):
+        orig = Series([1, 2, 3])
+        expected = orig.to_frame()
+        result = SimpleSeriesSubClass(orig).to_frame()
+
+        assert (
+            type(result) is DataFrame
+        )  # assert_frame_equal only checks isinstance(lhs, type(rhs))
+        tm.assert_frame_equal(result, expected)
+
+    def test_groupby(self):
+        df = SimpleDataFrameSubClass(DataFrame({"a": [1, 2, 3]}))
+
+        for _, v in df.groupby("a"):
+            assert type(v) is DataFrame