pandas-dev/pandas · #71492

ENH: add axis keyword to ExtensionArray._concat_same_type (GH#47514)

jbrockmendel · merged Nov 8, 202611 files · 60 + / 14 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -97,6 +97,7 @@ 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`)+- :meth:`.ExtensionArray._concat_same_type` now has an ``axis`` keyword (default ``0``); pandas only passes it for dtypes that support 2D, so existing implementations without it keep working (:issue:`47514`) - :meth:`.Styler.to_excel` now omits the rows, columns, index and header levels, and names hidden with :meth:`.Styler.hide`, matching the other :class:`.Styler` output methods; hiding the whole index with :class:`MultiIndex` columns now raises ``NotImplementedError``, as ``index=False`` does (:issue:`42276`) - :meth:`DataFrame.copy` and :meth:`Series.copy` with ``deep=True`` (the default), and :meth:`.ArrowExtensionArray.copy`, now copy pyarrow-backed data instead of sharing its memory, so a copy no longer keeps the original's memory alive (:issue:`61930`) - :meth:`DataFrame.query` now raises a ``ValueError`` when the expression does not evaluate to a boolean array, instead of silently using the result as row labels; use :attr:`DataFrame.loc` for label selection (:issue:`8560`)
pandas/core/arrays/arrow/array.py5 + / 1 −
@@ -2647,18 +2647,22 @@ def value_counts(self, dropna: bool = True) -> Series:         return Series(counts, index=index, name="count", copy=False)      @classmethod-    def _concat_same_type(cls, to_concat) -> Self:+    def _concat_same_type(cls, to_concat, axis: AxisInt = 0) -> Self:         """         Concatenate multiple ArrowExtensionArrays.          Parameters         ----------         to_concat : sequence of ArrowExtensionArrays+        axis : int, default 0+            Must be 0 or -1; ArrowExtensionArray is 1D-only.          Returns         -------         ArrowExtensionArray         """+        if axis not in (0, -1):+            raise ValueError(f"axis {axis} is out of bounds for array of dimension 1")         chunks = [array for ea in to_concat for array in ea._pa_array.iterchunks()]         if to_concat[0].dtype == "string":             # StringDtype has no attribute pyarrow_dtype
pandas/core/arrays/base.py4 + / 1 −
@@ -2396,7 +2396,7 @@ def ravel(self, order: Literal["C", "F", "A", "K"] | None = "C") -> Self:         return self      @classmethod-    def _concat_same_type(cls, to_concat: Sequence[Self]) -> Self:+    def _concat_same_type(cls, to_concat: Sequence[Self], axis: AxisInt = 0) -> Self:         """         Concatenate multiple arrays of this dtype. @@ -2408,6 +2408,9 @@ def _concat_same_type(cls, to_concat: Sequence[Self]) -> Self:         ----------         to_concat : sequence of this type             An array of the same dtype to concatenate.+        axis : int, default 0+            Axis along which to concatenate. pandas only passes the axis+            keyword for dtypes that support 2D (``dtype._supports_2d``).          Returns         -------
pandas/core/arrays/interval.py7 + / 1 −
@@ -1008,18 +1008,24 @@ def equals(self, other) -> bool:         )      @classmethod-    def _concat_same_type(cls, to_concat: Sequence[IntervalArray]) -> Self:+    def _concat_same_type(+        cls, to_concat: Sequence[IntervalArray], axis: AxisInt = 0+    ) -> Self:         """         Concatenate multiple IntervalArray          Parameters         ----------         to_concat : sequence of IntervalArray+        axis : int, default 0+            Must be 0 or -1; IntervalArray is 1D-only.          Returns         -------         IntervalArray         """+        if axis not in (0, -1):+            raise ValueError(f"axis {axis} is out of bounds for array of dimension 1")         closed_set = {interval.closed for interval in to_concat}         if len(closed_set) != 1:             raise ValueError("Intervals must all be closed on the same side.")
pandas/core/arrays/sparse/array.py3 + / 1 −
@@ -1698,7 +1698,9 @@ def copy(self) -> Self:         return self._simple_new(values, self.sp_index, self.dtype)      @classmethod-    def _concat_same_type(cls, to_concat: Sequence[Self]) -> Self:+    def _concat_same_type(cls, to_concat: Sequence[Self], axis: AxisInt = 0) -> Self:+        if axis not in (0, -1):+            raise ValueError(f"axis {axis} is out of bounds for array of dimension 1")         fill_value = to_concat[0].fill_value          values = []
pandas/core/dtypes/concat.py2 + / 10 −
@@ -104,12 +104,7 @@ def concat_compat(             # e.g. DatetimeArray             # NB: We are assuming here that ensure_wrapped_if_arraylike has             #  been called where relevant.-            return obj._concat_same_type(-                # error: Unexpected keyword argument "axis" for "_concat_same_type"-                # of "ExtensionArray"-                to_concat_eas,-                axis=axis,  # type: ignore[call-arg]-            )+            return obj._concat_same_type(to_concat_eas, axis=axis)      # If all arrays are empty, there's nothing to convert, just short-cut to     # the concatenation, #3121.@@ -134,10 +129,7 @@ def concat_compat(         if ea_compat_axis or axis == 0:             return cls._concat_same_type(to_concat_eas)         else:-            return cls._concat_same_type(-                to_concat_eas,-                axis=axis,  # type: ignore[call-arg]-            )+            return cls._concat_same_type(to_concat_eas, axis=axis)     else:         to_concat_arrs = cast("Sequence[np.ndarray]", to_concat)         result = np.concatenate(to_concat_arrs, axis=axis)
pandas/tests/arrays/test_array.py34 + / 0 −
@@ -761,3 +761,37 @@ def test_numpy_datetime_like_to_string(data, dtype, expected_data):     )      tm.assert_equal(result, expected)+++@pytest.mark.parametrize(+    "data, dtype",+    [+        ([1, 2, None], "Int64"),+        ([1.5, None], "Float64"),+        ([True, None], "boolean"),+        (["a", "b"], "category"),+        (["a", None], pd.StringDtype("python")),+        ([1, 0, 0], pd.SparseDtype("int64", 0)),+        ([pd.Interval(0, 1), pd.Interval(1, 2)], "interval"),+        (["2020-01-01", None], "M8[ns]"),+        (["2020-01-01", None], "M8[ns, UTC]"),+        (["1D", None], "m8[ns]"),+        (["2020-01", None], "period[M]"),+        ([1, 2], "object"),+        pytest.param([1, None], "int64[pyarrow]", marks=td.skip_if_no("pyarrow")),+        pytest.param(["a", None], "string[pyarrow]", marks=td.skip_if_no("pyarrow")),+    ],+)+def test_concat_same_type_axis_keyword(data, dtype):+    # GH#47514 every internal EA accepts the base class's axis keyword+    arr = pd.array(data, dtype=dtype)+    result = type(arr)._concat_same_type([arr, arr], axis=0)+    expected = type(arr)._concat_same_type([arr, arr])+    tm.assert_extension_array_equal(result, expected)+    assert len(result) == 2 * len(arr)+    result = type(arr)._concat_same_type([arr, arr], axis=-1)+    tm.assert_extension_array_equal(result, expected)++    # 1D arrays reject a non-zero axis rather than ignoring it+    with pytest.raises(ValueError, match="out of bounds"):+        type(arr)._concat_same_type([arr, arr], axis=1)
pandas/tests/extension/array_with_attr/array.py1 + / 0 −
@@ -82,6 +82,7 @@ def take(self, indexer, allow_fill=False, fill_value=None):     def copy(self):         return type(self)(self.data.copy(), self.attr) +    # no axis keyword, to test third-party EAs that lack it (GH#47514)     @classmethod     def _concat_same_type(cls, to_concat):         data = np.concatenate([x.data for x in to_concat])
pandas/tests/extension/decimal/array.py1 + / 0 −
@@ -250,6 +250,7 @@ def _formatter(self, boxed=False):             return "Decimal: {}".format         return repr +    # no axis keyword, to test third-party EAs that lack it (GH#47514)     @classmethod     def _concat_same_type(cls, to_concat):         return cls(np.concatenate([x._data for x in to_concat]))
pandas/tests/extension/json/array.py1 + / 0 −
@@ -249,6 +249,7 @@ def unique(self):         # a 2-dim object.         return type(self)([dict(x) for x in {tuple(d.items()) for d in self.data}]) +    # no axis keyword, to test third-party EAs that lack it (GH#47514)     @classmethod     def _concat_same_type(cls, to_concat):         data = list(itertools.chain.from_iterable(x.data for x in to_concat))
pandas/tests/extension/list/array.py1 + / 0 −
@@ -119,6 +119,7 @@ def astype(self, dtype, copy=True):         else:             return np.array(self.data, dtype=dtype, copy=copy) +    # no axis keyword, to test third-party EAs that lack it (GH#47514)     @classmethod     def _concat_same_type(cls, to_concat):         data = np.concatenate([x.data for x in to_concat])