diff --git a/python/cudf/cudf/core/column/numerical_base.py b/python/cudf/cudf/core/column/numerical_base.py index 658363362839..4d406e939865 100644 --- a/python/cudf/cudf/core/column/numerical_base.py +++ b/python/cudf/cudf/core/column/numerical_base.py @@ -90,13 +90,13 @@ def kurtosis(self, skipna: bool = True) -> float: ) if len(self) == 0 or self._can_return_nan(skipna=skipna): - return _get_nan_for_dtype(self.dtype) # type: ignore[return-value] + return _get_nan_for_dtype(self.dtype) original_dtype = self.dtype self = self.nans_to_nulls().dropna() if len(self) < 4: - return _get_nan_for_dtype(original_dtype) # type: ignore[return-value] + return _get_nan_for_dtype(original_dtype) if original_dtype.kind == "f" and original_dtype.itemsize < 8: # Compute in float64 to match pandas precision for narrower @@ -206,7 +206,7 @@ def quantile( except (TypeError, ValueError): pass return ( - _get_nan_for_dtype(self.dtype) # type: ignore[return-value] + _get_nan_for_dtype(self.dtype) if scalar_result is NA else scalar_result ) @@ -221,7 +221,7 @@ def median( ) if self._can_return_nan(skipna=skipna): - return _get_nan_for_dtype(self.dtype) # type: ignore[return-value] + return _get_nan_for_dtype(self.dtype) # enforce linear in case the default ever changes result = self.quantile( @@ -240,7 +240,7 @@ def cov(self, other: NumericalBaseColumn) -> float: or len(other) == 0 or (len(self) == 1 and len(other) == 1) ): - return _get_nan_for_dtype(self.dtype) # type: ignore[return-value] + return _get_nan_for_dtype(self.dtype) result = (self - self.mean()) * (other - other.mean()) cov_sample = result.sum() / (len(self) - 1) @@ -248,13 +248,13 @@ def cov(self, other: NumericalBaseColumn) -> float: def corr(self, other: NumericalBaseColumn) -> float: if len(self) == 0 or len(other) == 0: - return _get_nan_for_dtype(self.dtype) # type: ignore[return-value] + return _get_nan_for_dtype(self.dtype) cov = self.cov(other) lhs_std, rhs_std = self.std(), other.std() if not cov or lhs_std == 0 or rhs_std == 0: - return _get_nan_for_dtype(self.dtype) # type: ignore[return-value] + return _get_nan_for_dtype(self.dtype) return cov / lhs_std / rhs_std def round( diff --git a/python/cudf/cudf/core/column/string.py b/python/cudf/cudf/core/column/string.py index 933bb5b12303..6ead2278f9dd 100644 --- a/python/cudf/cudf/core/column/string.py +++ b/python/cudf/cudf/core/column/string.py @@ -29,6 +29,7 @@ pylibcudf_result_dtype_policy, same_dtype_policy, ) +from cudf.core.dtype.validators import is_dtype_obj_string from cudf.core.mixins import Scannable from cudf.errors import MixedTypeError from cudf.utils.dtypes import ( @@ -43,7 +44,7 @@ from cudf.utils.utils import is_na_like if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Mapping + from collections.abc import Callable, Iterable, Mapping, Sequence import cupy as cp @@ -204,11 +205,11 @@ def sum( if min_count > 0 and col.valid_count < min_count: return pd.NA - return ( - 0 - if len(col) == 0 - else col.join_strings("", None).element_indexing(0) - ) + if len(col) == 0: + # pandas sums an empty/all-null object series to the numeric + # identity 0; genuine string dtypes concatenate to "". + return 0 if self.dtype == np.dtype("object") else "" + return col.join_strings("", None).element_indexing(0) def any( self, skipna: bool = True, min_count: int = 0, **kwargs: Any @@ -397,6 +398,18 @@ def as_string_column(self, dtype: DtypeObj) -> Self: return cast("Self", ColumnBase.create(self.plc_column, dtype)) return self + def _process_values_for_isin( + self, values: Sequence | ColumnBase + ) -> tuple[ColumnBase, ColumnBase]: + lhs, rhs = super()._process_values_for_isin(values) + if lhs.dtype != rhs.dtype and is_dtype_obj_string(rhs.dtype): + # Strings of any dtype flavor (object, pd.StringDtype with + # python/pyarrow storage and NaN/NA na_value, pd.ArrowDtype + # string) hold comparable values; align rhs with lhs's dtype + # so ColumnBase.isin does not treat them as disjoint types. + rhs = rhs.astype(lhs.dtype) + return lhs, rhs + @property def values(self) -> cp.ndarray: """ diff --git a/python/cudf/cudf/pandas/fast_slow_proxy.py b/python/cudf/cudf/pandas/fast_slow_proxy.py index 6efb6cf27500..e228f5fd0589 100644 --- a/python/cudf/cudf/pandas/fast_slow_proxy.py +++ b/python/cudf/cudf/pandas/fast_slow_proxy.py @@ -1477,6 +1477,19 @@ def _transform_arg( transformed: list[Any] = [ _transform_arg(a, attribute_name, seen) for a in arg.flat ] + if all( + new is old for new, old in zip(transformed, arg.flat, strict=True) + ): + # No element needed transforming: return the original array + # (as we already do for non-object ndarrays below) to + # preserve buffer identity. Rebuilding an equivalent copy + # breaks aliasing checks such as + # ``np.may_share_memory(np.asarray(x), x)`` that numpy uses + # (e.g. in ``Generator.permutation``) to decide whether to + # defensively copy before an in-place shuffle; the views + # pandas returns under copy-on-write are read-only, so + # skipping that copy raises "ValueError: array is read-only". + return arg # Keep the same memory layout as arg (the default is C_CONTIGUOUS) if arg.flags["F_CONTIGUOUS"] and not arg.flags["C_CONTIGUOUS"]: order = "F" diff --git a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py index 104eb609bd4d..7dd7cdba117b 100644 --- a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py +++ b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py @@ -193,7 +193,6 @@ def pytest_unconfigure(config): "tests/arrays/categorical/test_indexing.py::TestCategoricalIndexing::test_where_new_category_raises": "TODO: Add a reason for failure", "tests/arrays/categorical/test_missing.py::TestCategoricalMissing::test_compare_categorical_with_missing[a10-a20-categories0]": "AssertionError: Attributes of Series are different", "tests/arrays/categorical/test_replace.py::test_replace_categorical_ea_dtype": "TODO: Add a reason for failure", - "tests/arrays/categorical/test_replace.py::test_replace_categorical_ea_dtype_different_cats_raises": "Failed: DID NOT RAISE ", "tests/arrays/categorical/test_sorting.py::TestCategoricalSort::test_sort_values": "TODO: Add a reason for failure", "tests/arrays/datetimes/test_constructors.py::TestDatetimeArrayConstructor::test_copy": "TODO: Add a reason for failure", "tests/arrays/floating/test_astype.py::test_astype_copy": "TODO: Add a reason for failure", @@ -266,27 +265,6 @@ def pytest_unconfigure(config): "tests/arrays/sparse/test_array.py::TestSparseArrayAnalytics::test_ufunc_args": "TODO: Add a reason for failure", "tests/arrays/sparse/test_array.py::test_array_interface": "TODO: Add a reason for failure", "tests/arrays/sparse/test_constructors.py::TestConstructors::test_constructor_copy": "TODO: Add a reason for failure", - "tests/arrays/string_/test_string.py::test_isin[string=str[python]]": "TODO: Add a reason for failure", - "tests/arrays/string_/test_string.py::test_isin[string=string[pyarrow]]": "TODO: Add a reason for failure", - "tests/arrays/string_/test_string.py::test_isin[string=string[python]]": "TODO: Add a reason for failure", - "tests/arrays/string_/test_string.py::test_min_max[False-string=str[pyarrow]-max]": "assert np.float64(nan) is nan", - "tests/arrays/string_/test_string.py::test_min_max[False-string=str[pyarrow]-min]": "assert np.float64(nan) is nan", - "tests/arrays/string_/test_string.py::test_min_max[False-string=str[python]-max]": "AssertionError: assert np.float64(nan) is nan", - "tests/arrays/string_/test_string.py::test_min_max[False-string=str[python]-min]": "AssertionError: assert np.float64(nan) is nan", - "tests/arrays/string_/test_string.py::test_min_max[False-string=string[pyarrow]-max]": "assert np.float64(nan) is ", - "tests/arrays/string_/test_string.py::test_min_max[False-string=string[pyarrow]-min]": "assert np.float64(nan) is ", - "tests/arrays/string_/test_string.py::test_min_max[False-string=string[python]-max]": "AssertionError: assert np.float64(nan) is ", - "tests/arrays/string_/test_string.py::test_min_max[False-string=string[python]-min]": "AssertionError: assert np.float64(nan) is ", - "tests/arrays/string_/test_string.py::test_numpy_random_permute[string=str[python]-Series]": "ValueError: array is read-only", - "tests/arrays/string_/test_string.py::test_numpy_random_permute[string=string[python]-Series]": "ValueError: array is read-only", - "tests/arrays/string_/test_string.py::test_reduce_empty[False-string=str[pyarrow]-0]": "AssertionError: assert 0 == ''", - "tests/arrays/string_/test_string.py::test_reduce_empty[False-string=str[python]-0]": "AssertionError: assert 0 == ''", - "tests/arrays/string_/test_string.py::test_reduce_empty[False-string=string[pyarrow]-0]": "AssertionError: assert 0 == ''", - "tests/arrays/string_/test_string.py::test_reduce_empty[False-string=string[python]-0]": "AssertionError: assert 0 == ''", - "tests/arrays/string_/test_string.py::test_reduce_empty[True-string=str[pyarrow]-0]": "AssertionError: assert 0 == ''", - "tests/arrays/string_/test_string.py::test_reduce_empty[True-string=str[python]-0]": "AssertionError: assert 0 == ''", - "tests/arrays/string_/test_string.py::test_reduce_empty[True-string=string[pyarrow]-0]": "AssertionError: assert 0 == ''", - "tests/arrays/string_/test_string.py::test_reduce_empty[True-string=string[python]-0]": "AssertionError: assert 0 == ''", "tests/arrays/string_/test_string_arrow.py::test_pickle_roundtrip[na_value0]": "https://github.com/rapidsai/cudf/issues/18659#issuecomment-3710985854", "tests/arrays/string_/test_string_arrow.py::test_pickle_roundtrip[nan]": "https://github.com/rapidsai/cudf/issues/18659#issuecomment-3710985854", "tests/arrays/test_datetimelike.py::TestDatetimeArray::test_array_interface[B]": "TODO: Add a reason for failure", @@ -366,9 +344,6 @@ def pytest_unconfigure(config): "tests/copy_view/test_array.py::test_dataframe_values[array]": "TODO: Add a reason for failure", "tests/copy_view/test_array.py::test_dataframe_values[asarray]": "TODO: Add a reason for failure", "tests/copy_view/test_array.py::test_dataframe_values[values]": "TODO: Add a reason for failure", - "tests/copy_view/test_array.py::test_series_array_string_dtype[string=object]": "AssertionError: assert False", - "tests/copy_view/test_array.py::test_series_array_string_dtype[string=str[python]]": "AssertionError: assert False", - "tests/copy_view/test_array.py::test_series_array_string_dtype[string=string[python]]": "AssertionError: assert False", "tests/copy_view/test_array.py::test_series_to_numpy": "TODO: Add a reason for failure", "tests/copy_view/test_array.py::test_series_values[array]": "TODO: Add a reason for failure", "tests/copy_view/test_array.py::test_series_values[np.array]": "AssertionError: assert False", diff --git a/python/cudf/cudf/tests/series/methods/test_isin.py b/python/cudf/cudf/tests/series/methods/test_isin.py index 9a168ae93c73..e49c83012ea2 100644 --- a/python/cudf/cudf/tests/series/methods/test_isin.py +++ b/python/cudf/cudf/tests/series/methods/test_isin.py @@ -1,8 +1,9 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import numpy as np import pandas as pd +import pyarrow as pa import pytest import cudf @@ -159,3 +160,98 @@ def test_isin_categorical(data, values): got = gsr.isin(values) expected = psr.isin(values) assert_eq(got, expected) + + +@pytest.mark.parametrize( + "dtype", + [ + np.dtype("object"), + pd.StringDtype(storage="python", na_value=pd.NA), + pd.StringDtype(storage="python", na_value=np.nan), + pd.StringDtype(storage="pyarrow", na_value=pd.NA), + pd.StringDtype(storage="pyarrow", na_value=np.nan), + pd.ArrowDtype(pa.string()), + pd.ArrowDtype(pa.large_string()), + ], +) +@pytest.mark.parametrize( + "values", + [ + ["b", "x"], + np.array(["b", "x"], dtype=object), + pd.array( + ["b", "x"], dtype=pd.StringDtype(storage="python", na_value=pd.NA) + ), + pd.array( + ["b", "x"], + dtype=pd.StringDtype(storage="python", na_value=np.nan), + ), + pd.array( + ["b", "x"], + dtype=pd.StringDtype(storage="pyarrow", na_value=pd.NA), + ), + pd.array( + ["b", "x"], + dtype=pd.StringDtype(storage="pyarrow", na_value=np.nan), + ), + pd.array(["b", "x"], dtype=pd.ArrowDtype(pa.string())), + pa.array(["b", "x"]), + ], +) +def test_isin_string_dtype_flavors(dtype, values): + # isin must match on element values regardless of which string dtype + # flavor the series and the values use (object, pd.StringDtype with + # python/pyarrow storage and NaN/NA na_value, pd.ArrowDtype string). + data = ["a", "b", "c"] + psr = pd.Series(data, dtype=dtype) + gsr = cudf.Series(data, dtype=dtype) + + got = gsr.isin(values) + if isinstance(values, pa.Array) and ( + dtype == np.dtype("object") + or (isinstance(dtype, pd.StringDtype) and dtype.storage == "python") + ): + # pandas does not match pyarrow.Array values against + # object/python-storage series (returns all-False); cudf matches + # on element values. + assert got.to_pandas().tolist() == [False, True, False] + else: + expected = psr.isin(values) + assert_eq(got, expected) + + +@pytest.mark.parametrize( + "dtype", + [ + np.dtype("object"), + pd.StringDtype(storage="python", na_value=pd.NA), + pd.StringDtype(storage="python", na_value=np.nan), + pd.StringDtype(storage="pyarrow", na_value=pd.NA), + pd.StringDtype(storage="pyarrow", na_value=np.nan), + pd.ArrowDtype(pa.string()), + pd.ArrowDtype(pa.large_string()), + ], +) +def test_isin_string_null_values(dtype): + # cudf treats every NA-like value in ``values`` (None, pd.NA) as a + # match for nulls in the series. Results are compared with pandas + # except where pandas diverges: object dtype distinguishes the + # stored None from pd.NA, and ArrowDtype raises ArrowTypeError when + # the value set contains nulls. + data = ["a", "b", None] + psr = pd.Series(data, dtype=dtype) + gsr = cudf.Series(data, dtype=dtype) + + got = gsr.isin([None]) + if isinstance(dtype, pd.ArrowDtype): + assert got.to_pandas().tolist() == [False, False, True] + else: + assert_eq(got, psr.isin([None])) + + got = gsr.isin(["a", pd.NA]) + if isinstance(dtype, pd.ArrowDtype) or dtype == np.dtype("object"): + assert got.to_pandas().tolist() == [True, False, True] + else: + assert_eq(got, psr.isin(["a", pd.NA])) + + assert_eq(gsr.isin([]), psr.isin([])) diff --git a/python/cudf/cudf/tests/series/methods/test_reductions.py b/python/cudf/cudf/tests/series/methods/test_reductions.py index 03fcb79ad848..2225ae386b36 100644 --- a/python/cudf/cudf/tests/series/methods/test_reductions.py +++ b/python/cudf/cudf/tests/series/methods/test_reductions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import re from concurrent.futures import ThreadPoolExecutor @@ -7,6 +7,7 @@ import cupy as cp import numpy as np import pandas as pd +import pyarrow as pa import pytest import cudf @@ -946,6 +947,109 @@ def test_string_reduction(): assert s.all(skipna=False) == ps.all(skipna=False) +@pytest.mark.parametrize("min_count", [0, 1]) +@pytest.mark.parametrize( + "dtype", + [ + np.dtype("object"), + pd.StringDtype(storage="python", na_value=pd.NA), + pd.StringDtype(storage="python", na_value=np.nan), + pd.StringDtype(storage="pyarrow", na_value=pd.NA), + pd.StringDtype(storage="pyarrow", na_value=np.nan), + pd.ArrowDtype(pa.string()), + pd.ArrowDtype(pa.large_string()), + ], +) +def test_string_sum_empty_and_all_null(dtype, skipna, min_count): + # Summing no elements returns the additive identity (0 for object + # dtype, "" for string dtypes, matching pandas + # tests/arrays/string_/test_string.py::test_reduce_empty); once + # min_count is not met the result is a missing value. cudf's missing + # sentinel is pd.NA where pandas uses np.nan for object dtype and + # "str" dtypes, so missing results are compared via pd.isna. + expected = pd.Series([], dtype=dtype).sum( + skipna=skipna, min_count=min_count + ) + result = cudf.Series([], dtype=dtype).sum( + skipna=skipna, min_count=min_count + ) + if pd.isna(expected): + assert pd.isna(result) + else: + assert result == expected + + # All-null input: nulls are skipped down to an empty sum. + result = cudf.Series([None, None], dtype=dtype).sum( + skipna=skipna, min_count=min_count + ) + if dtype == np.dtype("object") and not skipna: + # pandas raises TypeError (None + None); cudf treats the values + # as nulls and returns a missing value instead. + with pytest.raises(TypeError): + pd.Series([None, None], dtype=dtype).sum( + skipna=skipna, min_count=min_count + ) + assert pd.isna(result) + else: + expected = pd.Series([None, None], dtype=dtype).sum( + skipna=skipna, min_count=min_count + ) + if pd.isna(expected): + assert pd.isna(result) + else: + assert result == expected + + +@pytest.mark.parametrize("method", ["min", "max"]) +@pytest.mark.parametrize( + "dtype", + [ + pd.StringDtype(storage="python", na_value=pd.NA), + pd.StringDtype(storage="python", na_value=np.nan), + pd.StringDtype(storage="pyarrow", na_value=pd.NA), + pd.StringDtype(storage="pyarrow", na_value=np.nan), + pd.ArrowDtype(pa.string()), + pd.ArrowDtype(pa.large_string()), + ], +) +def test_string_min_max_null_identity(dtype, method, skipna): + # Matches pandas tests/arrays/string_/test_string.py::test_min_max: + # with skipna=False the result must be the dtype's exact na_value + # singleton (pd.NA for "string" dtypes and ArrowDtype, the np.nan + # float singleton for "str" dtypes). + data = ["a", "b", "c", None] + psr = pd.Series(data, dtype=dtype) + gsr = cudf.Series(data, dtype=dtype) + + expected = getattr(psr, method)(skipna=skipna) + result = getattr(gsr, method)(skipna=skipna) + + if skipna: + assert result == expected + else: + assert expected is dtype.na_value + assert result is expected + + +@pytest.mark.parametrize("method", ["min", "max"]) +def test_object_min_max_with_null(method, skipna): + # pandas raises TypeError here (comparing str with the missing + # value); cudf treats None as a null: skipped when skipna=True, + # otherwise the result is a missing value. + data = ["a", "b", "c", None] + with pytest.raises(TypeError): + getattr(pd.Series(data, dtype=np.dtype("object")), method)( + skipna=skipna + ) + + sr = cudf.Series(data, dtype=np.dtype("object")) + result = getattr(sr, method)(skipna=skipna) + if skipna: + assert result == ("a" if method == "min" else "c") + else: + assert pd.isna(result) + + @pytest.mark.parametrize("data", [[1, 2, 3], [], [1, 20, 1000, None]]) def test_datetime_stats(data, datetime_types_as_str, reduction_methods): if reduction_methods not in ["mean", "quantile"]: diff --git a/python/cudf/cudf/utils/dtypes.py b/python/cudf/cudf/utils/dtypes.py index 44ff11d553e3..75e9b0ffbf39 100644 --- a/python/cudf/cudf/utils/dtypes.py +++ b/python/cudf/cudf/utils/dtypes.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations @@ -18,7 +18,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from cudf._typing import DtypeObj + from cudf._typing import DtypeObj, ScalarLike from cudf.core.dtypes import DecimalDtype DEFAULT_STRING_DTYPE = pd.StringDtype(na_value=np.nan) @@ -269,11 +269,12 @@ def is_mixed_with_object_dtype(lhs, rhs): ) -def _get_nan_for_dtype(dtype: DtypeObj) -> np.generic: +def _get_nan_for_dtype(dtype: DtypeObj) -> ScalarLike: """Return the appropriate NaN/NaT value for the given dtype. - Returns a numpy scalar (np.generic subclass) representing the - null value for the dtype (e.g., np.float64('nan'), np.datetime64('NaT')). + Returns the null value for the dtype (e.g., np.float64('nan'), + np.datetime64('NaT'), or the dtype's ``na_value`` for pandas + nullable extension dtypes). """ if dtype.kind in "mM": time_unit, _ = np.datetime_data(dtype) @@ -283,10 +284,19 @@ def _get_nan_for_dtype(dtype: DtypeObj) -> np.generic: return dtype.na_value return dtype.type("nan") else: - if ( + if isinstance(dtype, pd.StringDtype) or ( is_pandas_nullable_extension_dtype(dtype) - and getattr(dtype, "kind", "c") in "biu" + and getattr(dtype, "kind", "c") in "biuU" ): + # dtype.na_value is pd.NA for masked, "string", and arrow + # string (kind "U") dtypes, and the np.nan float singleton + # for "str" dtypes (pandas>=3). Reductions like + # min/max(skipna=False) must return exactly that object so + # that ``result is dtype.na_value`` holds. Kind "O" extension + # dtypes other than StringDtype (e.g. categorical, arrow + # decimal/binary) are excluded: pandas coerces their + # skew/cov/corr results to a float NaN, which the fallback + # below matches. return dtype.na_value return np.float64("nan") diff --git a/python/cudf/cudf_pandas_tests/test_cudf_pandas.py b/python/cudf/cudf_pandas_tests/test_cudf_pandas.py index 516b82a2c6f4..cbf3e17bfdc4 100644 --- a/python/cudf/cudf_pandas_tests/test_cudf_pandas.py +++ b/python/cudf/cudf_pandas_tests/test_cudf_pandas.py @@ -2184,3 +2184,37 @@ def test_module_proxy_write_through_config(monkeypatch): cf.register_option("foo", 1) monkeypatch.setattr(cf, "_registered_options", {}) cf.register_option("foo", 1) + + +@pytest.mark.parametrize("box", ["Series", "array"]) +@pytest.mark.parametrize("na_value", [pd.NA, np.nan], ids=["NA", "NaN"]) +@pytest.mark.parametrize("storage", ["python", "pyarrow"]) +def test_numpy_random_permutation_string(storage, na_value, box): + # https://github.com/pandas-dev/pandas/issues/63935 + # Under copy-on-write, ``np.asarray`` on a python-storage string + # Series returns a read-only view; ``Generator.permutation`` relies + # on ``np.may_share_memory(np.asarray(x), x)`` to decide whether to + # copy before shuffling in place, which requires ``_transform_arg`` + # to preserve the identity of object-dtype ndarrays that need no + # transformation (otherwise this raises "ValueError: array is + # read-only"). + data = ["a", "bb", "ccc"] + obj = getattr(xpd, box)( + data, dtype=xpd.StringDtype(storage=storage, na_value=na_value) + ) + pandas_obj = getattr(pd, box)( + data, dtype=pd.StringDtype(storage=storage, na_value=na_value) + ) + + result = np.random.default_rng(seed=2).permutation(obj) + expected = np.random.default_rng(seed=2).permutation(pandas_obj) + assert isinstance(result, np.ndarray) + assert result.tolist() == expected.tolist() + + # The original object is left untouched by the shuffle. + tm.assert_equal( + obj, + getattr(xpd, box)( + data, dtype=xpd.StringDtype(storage=storage, na_value=na_value) + ), + ) diff --git a/python/cudf/cudf_pandas_tests/test_fast_slow_proxy.py b/python/cudf/cudf_pandas_tests/test_fast_slow_proxy.py index 32185410a17c..8be51f2b6c90 100644 --- a/python/cudf/cudf_pandas_tests/test_fast_slow_proxy.py +++ b/python/cudf/cudf_pandas_tests/test_fast_slow_proxy.py @@ -614,6 +614,32 @@ def __getnewargs_ex__(self): ) +@pytest.mark.parametrize("attribute_name", ["_fsproxy_fast", "_fsproxy_slow"]) +def test_transform_arg_preserves_object_ndarray_identity( + attribute_name, final_proxy +): + # An object-dtype ndarray whose elements need no transformation must + # be returned as-is rather than rebuilt: an equivalent copy breaks + # aliasing checks such as ``np.may_share_memory(np.asarray(x), x)`` + # that numpy uses (e.g. in ``Generator.permutation``) to decide + # whether to defensively copy before an in-place shuffle. + transform = partial( + _transform_arg, attribute_name=attribute_name, seen=set() + ) + arr = np.array(["a", "bb", "ccc"], dtype=object) + assert transform(arr) is arr + + # An object-dtype ndarray containing a proxy is still rebuilt with + # the unwrapped elements. + fast_x, slow_x, x = final_proxy + expected = fast_x if attribute_name == "_fsproxy_fast" else slow_x + arr_with_proxy = np.array(["a", x], dtype=object) + result = transform(arr_with_proxy) + assert result is not arr_with_proxy + assert result[0] == "a" + assert type(result[1]) is type(expected) + + def test_tuple_with_attrs_transform(): Bunch = tuple_with_attrs("Bunch", ["a", "b"], {"c", "d"}) Bunch2 = tuple_with_attrs("Bunch", ["a", "b"], {"c", "d"})