pandas-dev/pandas · #70740

ENH: implement ExtensionArray.__array_function__ (GH#26380)

jbrockmendel · merged Oct 26, 20265 files · 252 + / 3 −
doc/source/whatsnew/v3.2.0.rst2 + / 0 −
@@ -28,6 +28,7 @@ enhancement2  Other enhancements ^^^^^^^^^^^^^^^^^^+- :func:`numpy.delete` on an :class:`ExtensionArray`, and :func:`numpy.concatenate` on :class:`ExtensionArray` objects of the same dtype, now return an :class:`ExtensionArray` of that dtype instead of a NumPy array (:issue:`26380`) - :func:`read_csv` and :func:`read_table` with ``sep=None`` now sniff the separator with the C engine instead of falling back to the slower python engine (:issue:`9645`) - :func:`read_csv` with the ``"c"`` and ``"python"`` engines now accepts a :class:`DatetimeTZDtype` in ``dtype`` (:issue:`24542`) - :meth:`.DataFrameGroupBy.rank` and :meth:`.SeriesGroupBy.rank` now accept a ``numeric_only`` argument, matching :meth:`DataFrame.rank` (:issue:`44438`)@@ -88,6 +89,7 @@ See :ref:`install.dependencies` and :ref:`install.optional_dependencies` for mor Other API changes ^^^^^^^^^^^^^^^^^ - :class:`.Styler` now displays missing float values as ``NaN`` rather than ``nan`` by default, matching :class:`DataFrame` (:issue:`12710`)+- :class:`ExtensionArray` now implements ``__array_function__``. Code expecting :func:`numpy.concatenate` or :func:`numpy.delete` to return a NumPy array for :class:`ExtensionArray` input, including ``_concat_same_type`` and ``delete`` implementations in :class:`ExtensionArray` subclasses, may need updating, as may code mixing an :class:`ExtensionArray` with another library's ``__array_function__`` arrays (:issue:`26380`) - :class:`Index` now displays missing float values as ``NaN`` rather than ``nan`` in its repr, matching :class:`Series` and :class:`DataFrame` (:issue:`64733`)  .. ---------------------------------------------------------------------------
pandas/core/arrays/base.py80 + / 0 −
@@ -9,6 +9,7 @@  from __future__ import annotations +import contextvars import operator from typing import (     TYPE_CHECKING,@@ -2952,6 +2953,38 @@ def round(self, decimals: int = 0) -> Self:         ]         return type(self)._from_sequence(rounded, dtype=self.dtype) +    def __array_function__(self, func, types, args, kwargs):+        for typ in types:+            # defer to other types that override __array_function__; a subclass+            #  delegating here with super() is not "other"+            if not isinstance(self, typ) and typ.__array_function__ not in (+                ExtensionArray.__array_function__,+                np.ndarray.__array_function__,+            ):+                return NotImplemented++        override = None+        if not _IN_ARRAY_FUNCTION.get():+            override = _ARRAY_FUNCTION_OVERRIDES.get(func)+        implementation = getattr(func, "_implementation", None)+        if override is None and implementation is None:+            return NotImplemented++        # numpy calls made while handling this one (e.g. np.setxor1d calling+        #  np.concatenate) skip the overrides+        token = _IN_ARRAY_FUNCTION.set(True)+        try:+            if override is not None:+                result = override(*args, **kwargs)+                if result is not NotImplemented:+                    return result+            if implementation is None:+                return NotImplemented+            # numpy's own default, see ndarray.__array_function__ in NEP 18+            return implementation(*args, **kwargs)+        finally:+            _IN_ARRAY_FUNCTION.reset(token)+     def __array_ufunc__(self, ufunc: np.ufunc, method: str, *inputs, **kwargs):         # the fallback at the end of this method np.asarray()s the values: M8/m8         #  survive to be truth-tested, the rest flatten to object@@ -3235,6 +3268,53 @@ def _groupby_first_last(         return self.take(result_indices, allow_fill=True)  +# -----------------------------------------------------------------------------+# __array_function__ helpers++# True while ExtensionArray.__array_function__ is handling a call+_IN_ARRAY_FUNCTION = contextvars.ContextVar("_IN_ARRAY_FUNCTION", default=False)+++# Overrides so these keep the extension dtype. Returning NotImplemented falls+#  back to numpy's implementation.+++def _delete(arr, obj, axis=None):+    # the ExtensionArray may be obj rather than arr+    if (+        not isinstance(arr, ExtensionArray)+        or arr.ndim != 1+        or axis not in (None, 0, -1)+    ):+        return NotImplemented+    return arr.delete(obj)+++def _concatenate(arrays, axis=0, out=None, dtype=None, casting="same_kind"):+    if (+        not isinstance(arrays, (list, tuple))+        or not arrays+        or axis not in (None, 0, -1)+        or out is not None+        or dtype is not None+        or casting != "same_kind"+    ):+        return NotImplemented+    cls = type(arrays[0])+    if not issubclass(cls, ExtensionArray) or not all(+        type(arr) is cls and arr.ndim == 1 and arr.dtype == arrays[0].dtype+        for arr in arrays+    ):+        return NotImplemented+    return cls._concat_same_type(arrays)+++_ARRAY_FUNCTION_OVERRIDES: dict[Callable[..., Any], Callable[..., Any]] = {+    np.delete: _delete,+    np.concatenate: _concatenate,+}++ class ExtensionArrayNaResult(ExtensionArray):     @overload     def any(self, *, skipna: Literal[True] = ...) -> bool: ...
pandas/tests/arrays/test_array_function.pyadded160 + / 0 −
@@ -0,0 +1,160 @@+"""+Tests for ExtensionArray.__array_function__ beyond the extension base tests.+"""++import numpy as np+import pytest++import pandas as pd+import pandas._testing as tm+from pandas.arrays import (+    NumpyExtensionArray,+    SparseArray,+)+++def test_delete_with_extension_array_obj():+    # GH#26380 the ExtensionArray is the indexer, not the array+    result = np.delete(np.arange(3), pd.array([0]))+    tm.assert_numpy_array_equal(result, np.array([1, 2]))++    result = pd.Index([1, 2, 3]).delete(pd.array([0]))+    tm.assert_index_equal(result, pd.Index([2, 3]))+++@pytest.mark.parametrize("axis", [0, -1, None])+def test_axis_spellings(axis):+    arr = pd.array([1, None, 3], dtype="Int64")+    result = np.delete(arr, 0, axis=axis)+    tm.assert_extension_array_equal(result, arr[1:])++    result = np.concatenate([arr, arr[:1]], axis=axis)+    tm.assert_extension_array_equal(result, arr.take([0, 1, 2, 0]))+++@pytest.mark.parametrize(+    "kwargs",+    [{"dtype": object}, {"axis": None, "dtype": object}, {"casting": "unsafe"}],+)+def test_concatenate_with_kwargs_falls_back(kwargs):+    arr = pd.array([1, None], dtype="Int64")+    result = np.concatenate([arr, arr], **kwargs)+    expected = np.concatenate([np.asarray(arr)] * 2, **kwargs)+    tm.assert_numpy_array_equal(result, expected)+++def test_concatenate_out_falls_back():+    arr = pd.array([1, None], dtype="Int64")+    out = np.empty(4, dtype=object)+    result = np.concatenate([arr, arr], out=out)+    assert result is out+    expected = np.concatenate([np.asarray(arr)] * 2, out=np.empty(4, dtype=object))+    tm.assert_numpy_array_equal(out, expected)+++@pytest.mark.parametrize(+    "other",+    [+        pd.array([1.5], dtype="Float64"),+        pd.Categorical([1]),+        np.array([1]),+    ],+)+def test_concatenate_mismatched_falls_back(other):+    arr = pd.array([1, None], dtype="Int64")+    result = np.concatenate([arr, other])+    expected = np.concatenate([np.asarray(arr), np.asarray(other)])+    tm.assert_numpy_array_equal(result, expected)+++@pytest.mark.parametrize(+    "left, right",+    [+        (pd.Categorical(["a"]), pd.Categorical(["b"])),+        (+            pd.array(pd.date_range("2000", periods=2)),+            pd.array(pd.date_range("2000", periods=2, tz="UTC")),+        ),+    ],+)+def test_concatenate_same_type_different_dtype_falls_back(left, right):+    result = np.concatenate([left, right])+    expected = np.concatenate([np.asarray(left), np.asarray(right)])+    tm.assert_numpy_array_equal(result, expected)+++def test_2d_falls_back():+    arr = pd.date_range("2000", periods=4)._data.reshape(2, 2)+    expected = np.asarray(arr)++    result = np.delete(arr, 0, axis=0)+    tm.assert_numpy_array_equal(result, np.delete(expected, 0, axis=0))++    result = np.concatenate([arr, arr])+    tm.assert_numpy_array_equal(result, np.concatenate([expected, expected]))+++class ArrayFunctionOverride:+    def __array_function__(self, func, types, args, kwargs):+        return "override"+++def test_defers_to_other_array_function():+    arr = pd.array([1, 2], dtype="Int64")+    other = ArrayFunctionOverride()+    assert np.concatenate([arr, other]) == "override"+    assert np.concatenate([other, arr]) == "override"+++def test_defers_to_extension_array_subclass_override():+    class SubclassOverride(NumpyExtensionArray):+        def __array_function__(self, func, types, args, kwargs):+            return "override"++    arr = pd.array([1, 2], dtype="Int64")+    other = SubclassOverride(np.array([3, 4]))+    assert np.concatenate([arr, other]) == "override"+++def test_subclass_override_delegating_to_super():+    class DelegatingOverride(NumpyExtensionArray):+        def __array_function__(self, func, types, args, kwargs):+            return super().__array_function__(func, types, args, kwargs)++    arr = DelegatingOverride(np.array([1, 2, 3]))+    assert np.mean(arr) == 2.0+    result = np.delete(arr, 0)+    tm.assert_extension_array_equal(result, DelegatingOverride(np.array([2, 3])))+++@pytest.mark.parametrize(+    "left, right",+    [+        (pd.array([3, None, 2], dtype="Int64"), pd.array([2, None], dtype="Int64")),+        (SparseArray([3, 1, 2]), SparseArray([2, 5])),+    ],+)+def test_nested_numpy_calls_unchanged(left, right):+    # GH#26380 np.setxor1d calls np.concatenate internally, which must keep+    #  returning an ndarray there+    result = np.setxor1d(left, right, assume_unique=True)+    expected = np.setxor1d(np.asarray(left), np.asarray(right), assume_unique=True)+    tm.assert_numpy_array_equal(result, expected)+++def test_concat_same_type_calling_np_concatenate():+    # GH#26380 a subclass implementing _concat_same_type with np.concatenate+    #  must not recurse endlessly, whether called via numpy or directly+    class ConcatWithNumpy(NumpyExtensionArray):+        @classmethod+        def _concat_same_type(cls, to_concat):+            return cls(np.concatenate(to_concat))++    arr = ConcatWithNumpy(np.arange(3))+    expected = ConcatWithNumpy(np.array([0, 1, 2, 0, 1, 2]))++    result = np.concatenate([arr, arr])+    tm.assert_extension_array_equal(result, expected)++    result = ConcatWithNumpy._concat_same_type([arr, arr])+    tm.assert_extension_array_equal(result, expected)
pandas/tests/extension/base/methods.py4 + / 3 −
@@ -815,12 +815,13 @@ def test_repeat_raises(self, data, repeats, kwargs, error, msg, use_numpy):             else:                 data.repeat(repeats, **kwargs) -    def test_delete(self, data):-        result = data.delete(0)+    def test_delete(self, data, use_numpy):+        # GH#26380 np.delete keeps the dtype+        result = np.delete(data, 0) if use_numpy else data.delete(0)         expected = data[1:]         tm.assert_extension_array_equal(result, expected) -        result = data.delete([1, 3])+        result = np.delete(data, [1, 3]) if use_numpy else data.delete([1, 3])         expected = data._concat_same_type([data[[0]], data[[2]], data[4:]])         tm.assert_extension_array_equal(result, expected) 
pandas/tests/extension/base/reshaping.py6 + / 0 −
@@ -33,6 +33,12 @@ def test_concat(self, data, in_frame):             assert isinstance(result._mgr.blocks[0], EABackedBlock)         assert isinstance(result._mgr.blocks[0].values, ExtensionArray) +    def test_np_concatenate(self, data):+        # GH#26380 np.concatenate on matching dtypes keeps the dtype+        result = np.concatenate([data, data[:3]])+        expected = data._concat_same_type([data, data[:3]])+        tm.assert_extension_array_equal(result, expected)+     @pytest.mark.parametrize("in_frame", [True, False])     def test_concat_all_na_block(self, data_missing, in_frame):         valid_block = pd.Series(data_missing.take([1, 1]), index=range(2))