Repository: pandas-dev/pandas
Pull request: TST: base test for ExtensionArray.astype to its own type + copy keyword
- [] closes #28488
- [x] tests added / passed
- [x] passes `black pandas`
- [x] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
n.b. I'm not certain. I get no output on the command line when I run this command.
- [x] whatsnew entry


Problem statement:
Add base test for ExtensionArray.astype and copy
We should be able to test astype to our own type

```diff
diff --git a/pandas/tests/extension/base/casting.py b/pandas/tests/extension/base/casting.py
index 7146443bf8..b33ab93031 100644
--- a/pandas/tests/extension/base/casting.py
+++ b/pandas/tests/extension/base/casting.py
@@ -1,3 +1,5 @@
+import pytest
+
 import pandas as pd
 from pandas.core.internals import ObjectBlock
 
@@ -21,3 +23,9 @@ class BaseCastingTests(BaseExtensionTests):
         result = pd.Series(data[:5]).astype(str)
         expected = pd.Series(data[:5].astype(str))
         self.assert_series_equal(result, expected)
+
+    @pytest.mark.parametrize('copy', [True, False])
+    def test_astype_own_type(self, data, copy):
+        result = data.astype(data.dtype, copy=copy)
+        assert (result is data) is (not copy)
+        self.assert_extension_array_equal(result, data)
```

A few tests fail this. Would welcome investigation.

`$ pytest pandas/tests/extension/ -k test_astype_own_type -v --tb=line`

```
========================================================================= FAILURES ==========================================================================
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/core/arrays/base.py:390: TypeError: data type not understood
/Users/taugspurger/sandbox/pandas/pandas/core/arrays/base.py:390: TypeError: data type not understood
/Users/taugspurger/sandbox/pandas/pandas/core/arrays/base.py:390: TypeError: data type not understood
/Users/taugspurger/sandbox/pandas/pandas/core/arrays/base.py:390: TypeError: data type not understood
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
/Users/taugspurger/sandbox/pandas/pandas/tests/extension/base/casting.py:30: AssertionError
```

Hints from issue discussion:
I'm assuming we do want this property. It's what NumPy does

```
In [27]: a = np.array([1, 2, 3])

In [28]: a.astype(a.dtype, copy=False) is a
Out[28]: True
```

https://github.com/pandas-dev/pandas/blob/5a227a410c520ceec2d94369a44e2ab774a40dc3/pandas/core/arrays/integer.py#L518 may need an additional branch checking if `self.dtype` exactly matches `dtype`. In which case we just return `self` when `copy=False`.

We'll also want to update the docs in ExtensionArray.astype that this should return `self` when the dtype doesn't change and `copy=False`.
Hi! I'd like to work on this issue.
Thanks! Let us know if you need help getting started.
I would like to work on this issue, can you help me where to start
I think @jimmybeckett may already be working on it.
Hi, can I work on this issue?
@IsabellaRO that'd be great. I don't think anyone has submitted a pull request for it yet.
Given that there hasn't been any activity on this issue for a few months, I figured it would be okay to jump on this issue. I looked into the first failed test, which fails on the BooleanArray. I want to walk through my understanding of the problem and solution, @TomAugspurger, to make sure I'm getting this right. I'm going to be a little verbose for clarity's sake.

The first test that fails:
```
pandas/tests/extension/test_boolean.py::TestCasting::test_astype_own_type[True] PASSED           [  2%]
pandas/tests/extension/test_boolean.py::TestCasting::test_astype_own_type[False] FAILED                                           [  5%]
```

So I dropped in with the --pdb flag to see what's happening.

```
 55  	    @pytest.mark.parametrize('copy', [True, False])
 56  	    def test_astype_own_type(self, data, copy):
 57  	        result = data.astype(data.dtype, copy=copy)
 58  ->	        assert (result is data) is (not copy)
 59  	        self.assert_extension_array_equal(result, data)
```

Turns out result and data are different objects, according to id().  That must be happening when result is created from data.astype, so I drop into pandas.core.array.boolean.BooleanArray.astype and add a breakpoint...

```
    def astype(self, dtype, copy: bool = True) -> ArrayLike:
        """
        Got rid of the docstring to shorten this.
        """
        from pandas.core.arrays.string_ import StringDtype

        dtype = pandas_dtype(dtype)

        if isinstance(dtype, BooleanDtype):
            values, mask = coerce_to_array(self, copy=copy)
            if not copy:
                import pdb; pdb.set_trace()
            return BooleanArray(values, mask, copy=False)
```
And test equality of the parts
(Pdb) self._data is values
True
(Pdb) self._mask is mask
True

This points to that BooleanArray constructor call as the culprit. I think the solution is to check whether copy is False, and if so, then simply return self instead of calling that constructor.

e.g.

```
    def astype(self, dtype, copy: bool = True) -> ArrayLike:
        """
        Got rid of the docstring to shorten this.
        """
        from pandas.core.arrays.string_ import StringDtype

        dtype = pandas_dtype(dtype)

        if isinstance(dtype, BooleanDtype):
            values, mask = coerce_to_array(self, copy=copy)
            if copy:
                return BooleanArray(values, mask, copy=False)
            else:
                return self
```

The changes above make the test pass.
Everything seem good, @TomAugspurger? If so I'll continue w/ the other tests. It wouldn't surprise me if the issues are very similar. 
@tomaszps that looks right, thanks.
I'm a little confused about a detail- pandas.core.arrays.base.ExtensionArray.astype always coerces to and returns a numpy array. This is not the desired behavior, correct? That seems inconsistent with the way other subclasses of extensionarray act, where the method is overridden (e.g. Integer, Boolean extensionarrays).

Commit message: Fix .rst typo

Patch to review:
diff --git a/doc/source/whatsnew/v1.2.0.rst b/doc/source/whatsnew/v1.2.0.rst
index 5882b74aa8b05..f66b0ae706c48 100644
--- a/doc/source/whatsnew/v1.2.0.rst
+++ b/doc/source/whatsnew/v1.2.0.rst
@@ -361,6 +361,7 @@ ExtensionArray
 ^^^^^^^^^^^^^^
 
 - Fixed Bug where :class:`DataFrame` column set to scalar extension type via a dict instantion was considered an object type rather than the extension type (:issue:`35965`)
+- Fixed bug where `ExtensionArray.astype(dtype=?????, copy=False)` would return a new object (:issue:`284881`)
 -
 
 
diff --git a/pandas/core/arrays/base.py b/pandas/core/arrays/base.py
index e93cdb608dffb..eae401f9744f0 100644
--- a/pandas/core/arrays/base.py
+++ b/pandas/core/arrays/base.py
@@ -457,6 +457,11 @@ def astype(self, dtype, copy=True):
         from pandas.core.arrays.string_ import StringDtype
 
         dtype = pandas_dtype(dtype)
+        if is_dtype_equal(dtype, self.dtype):
+            if not copy:
+                return self
+            elif copy:
+                return self.copy()
         if isinstance(dtype, StringDtype):  # allow conversion to StringArrays
             return dtype.construct_array_type()._from_sequence(self, copy=False)
 
diff --git a/pandas/core/arrays/boolean.py b/pandas/core/arrays/boolean.py
index bd4bdc5ecb46f..3bd36209b3c71 100644
--- a/pandas/core/arrays/boolean.py
+++ b/pandas/core/arrays/boolean.py
@@ -375,7 +375,10 @@ def astype(self, dtype, copy: bool = True) -> ArrayLike:
 
         if isinstance(dtype, BooleanDtype):
             values, mask = coerce_to_array(self, copy=copy)
-            return BooleanArray(values, mask, copy=False)
+            if not copy:
+                return self
+            else:
+                return BooleanArray(values, mask, copy=False)
         elif isinstance(dtype, StringDtype):
             return dtype.construct_array_type()._from_sequence(self, copy=False)
 
diff --git a/pandas/core/arrays/period.py b/pandas/core/arrays/period.py
index 44c0455018a42..372ef7df9dc3a 100644
--- a/pandas/core/arrays/period.py
+++ b/pandas/core/arrays/period.py
@@ -33,6 +33,7 @@
     TD64NS_DTYPE,
     ensure_object,
     is_datetime64_dtype,
+    is_dtype_equal,
     is_float_dtype,
     is_period_dtype,
     pandas_dtype,
@@ -582,7 +583,11 @@ def astype(self, dtype, copy: bool = True):
         # We handle Period[T] -> Period[U]
         # Our parent handles everything else.
         dtype = pandas_dtype(dtype)
-
+        if is_dtype_equal(dtype, self._dtype):
+            if not copy:
+                return self
+            elif copy:
+                return self.copy()
         if is_period_dtype(dtype):
             return self.asfreq(dtype.freq)
         return super().astype(dtype, copy=copy)
diff --git a/pandas/core/arrays/sparse/array.py b/pandas/core/arrays/sparse/array.py
index c88af77ea6189..528d78a5414ea 100644
--- a/pandas/core/arrays/sparse/array.py
+++ b/pandas/core/arrays/sparse/array.py
@@ -1063,6 +1063,11 @@ def astype(self, dtype=None, copy=True):
         IntIndex
         Indices: array([2, 3], dtype=int32)
         """
+        if is_dtype_equal(dtype, self._dtype):
+            if not copy:
+                return self
+            elif copy:
+                return self.copy()
         dtype = self.dtype.update_dtype(dtype)
         subtype = dtype._subtype_with_str
         # TODO copy=False is broken for astype_nansafe with int -> float, so cannot
diff --git a/pandas/tests/extension/base/casting.py b/pandas/tests/extension/base/casting.py
index 3aaf040a4279b..039b42210224e 100644
--- a/pandas/tests/extension/base/casting.py
+++ b/pandas/tests/extension/base/casting.py
@@ -1,4 +1,5 @@
 import numpy as np
+import pytest
 
 import pandas as pd
 from pandas.core.internals import ObjectBlock
@@ -56,3 +57,11 @@ def test_astype_empty_dataframe(self, dtype):
         df = pd.DataFrame()
         result = df.astype(dtype)
         self.assert_frame_equal(result, df)
+
+    @pytest.mark.parametrize("copy", [True, False])
+    def test_astype_own_type(self, data, copy):
+        # ensure that astype returns the original object for equal dtype and copy=False
+        # https://github.com/pandas-dev/pandas/issues/28488
+        result = data.astype(data.dtype, copy=copy)
+        assert (result is data) is (not copy)
+        self.assert_extension_array_equal(result, data)
diff --git a/pandas/tests/extension/decimal/array.py b/pandas/tests/extension/decimal/array.py
index 9147360e71c73..2895f33d5c887 100644
--- a/pandas/tests/extension/decimal/array.py
+++ b/pandas/tests/extension/decimal/array.py
@@ -7,7 +7,7 @@
 import numpy as np
 
 from pandas.core.dtypes.base import ExtensionDtype
-from pandas.core.dtypes.common import pandas_dtype
+from pandas.core.dtypes.common import is_dtype_equal, pandas_dtype
 
 import pandas as pd
 from pandas.api.extensions import no_default, register_extension_dtype
@@ -131,9 +131,12 @@ def copy(self):
         return type(self)(self._data.copy())
 
     def astype(self, dtype, copy=True):
+        if is_dtype_equal(dtype, self._dtype):
+            if not copy:
+                return self
         dtype = pandas_dtype(dtype)
         if isinstance(dtype, type(self.dtype)):
-            return type(self)(self._data, context=dtype.context)
+            return type(self)(self._data, copy=copy, context=dtype.context)
 
         return super().astype(dtype, copy=copy)
 
diff --git a/pandas/tests/extension/test_numpy.py b/pandas/tests/extension/test_numpy.py
index bbfaacae1b444..cdcdb09b58e28 100644
--- a/pandas/tests/extension/test_numpy.py
+++ b/pandas/tests/extension/test_numpy.py
@@ -177,7 +177,7 @@ def test_take_series(self, data):
 
     def test_loc_iloc_frame_single_dtype(self, data, request):
         npdtype = data.dtype.numpy_dtype
-        if npdtype == object or npdtype == np.float64:
+        if npdtype == object:
             # GH#33125
             mark = pytest.mark.xfail(
                 reason="GH#33125 astype doesn't recognize data.dtype"
@@ -191,14 +191,6 @@ class TestGroupby(BaseNumPyTests, base.BaseGroupbyTests):
     def test_groupby_extension_apply(
         self, data_for_grouping, groupby_apply_op, request
     ):
-        # ValueError: Names should be list-like for a MultiIndex
-        a = "a"
-        is_identity = groupby_apply_op(a) is a
-        if data_for_grouping.dtype.numpy_dtype == np.float64 and is_identity:
-            mark = pytest.mark.xfail(
-                reason="GH#33125 astype doesn't recognize data.dtype"
-            )
-            request.node.add_marker(mark)
         super().test_groupby_extension_apply(data_for_grouping, groupby_apply_op)
 
 
@@ -306,9 +298,6 @@ def test_arith_series_with_array(self, data, all_arithmetic_operators):
 
 
 class TestPrinting(BaseNumPyTests, base.BasePrintingTests):
-    @pytest.mark.xfail(
-        reason="GH#33125 PandasArray.astype does not recognize PandasDtype"
-    )
     def test_series_repr(self, data):
         super().test_series_repr(data)
 


Code review comments:
diff_hunk: @@ -306,9 +298,6 @@ def test_arith_series_with_array(self, data, all_arithmetic_operators):
 
 
 class TestPrinting(BaseNumPyTests, base.BasePrintingTests):
-    @pytest.mark.xfail(
-        reason="GH#33125 PandasArray.astype does not recognize PandasDtype"
-    )
     def test_series_repr(self, data):
         super().test_series_repr(data)
path: pandas/tests/extension/test_numpy.py
text: @user1:
```suggestion
    pass
```

Since the test is now fully working, we can inherit it from the base class, I suppose