From 9a978b452916c8b1e62c728ca8aa8ebb770fceec Mon Sep 17 00:00:00 2001 From: galipremsagar Date: Wed, 1 Jul 2026 00:19:32 +0000 Subject: [PATCH 1/3] fix --- .../cudf/cudf/core/accessors/categorical.py | 8 ++- python/cudf/cudf/core/accessors/lists.py | 5 +- python/cudf/cudf/core/accessors/string.py | 17 +++++- python/cudf/cudf/core/accessors/struct.py | 5 +- python/cudf/cudf/core/column/datetime.py | 16 ++++- python/cudf/cudf/core/index.py | 10 ++- python/cudf/cudf/core/series.py | 40 +++++++++--- .../pandas/scripts/pandas-testing-plugin.py | 14 ----- .../indexes/datetimeindex/test_attributes.py | 21 ++++++- .../cudf/tests/series/accessors/test_cat.py | 22 ++++++- .../cudf/tests/series/accessors/test_dt.py | 61 ++++++++++++++++++- .../cudf/tests/series/accessors/test_list.py | 15 +++++ .../cudf/tests/series/accessors/test_str.py | 31 ++++++++++ .../tests/series/accessors/test_struct.py | 18 +++++- 14 files changed, 247 insertions(+), 36 deletions(-) diff --git a/python/cudf/cudf/core/accessors/categorical.py b/python/cudf/cudf/core/accessors/categorical.py index 405eef35bb14..f0ee777c75a8 100644 --- a/python/cudf/cudf/core/accessors/categorical.py +++ b/python/cudf/cudf/core/accessors/categorical.py @@ -1,4 +1,4 @@ -# 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 from __future__ import annotations @@ -90,11 +90,15 @@ def codes(self) -> Series: Return Series of codes as well as the index. """ from cudf.core.series import Series + from cudf.utils.dtypes import min_signed_type index = ( self._parent.index if isinstance(self._parent, Series) else None ) - return Series._from_column(self._column.codes, index=index) + codes = self._column.codes.astype( + min_signed_type(len(self._column.dtype.categories)) + ) + return Series._from_column(codes, index=index) @property def ordered(self) -> bool | None: diff --git a/python/cudf/cudf/core/accessors/lists.py b/python/cudf/cudf/core/accessors/lists.py index 87f9deae696c..13708e7ca885 100644 --- a/python/cudf/cudf/core/accessors/lists.py +++ b/python/cudf/cudf/core/accessors/lists.py @@ -1,4 +1,4 @@ -# 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 from __future__ import annotations @@ -32,7 +32,8 @@ class ListMethods(BaseAccessor): def __init__(self, parent: Series | Index): if not is_dtype_obj_list(parent.dtype): raise AttributeError( - "Can only use .list accessor with a 'list' dtype" + "Can only use the '.list' accessor with 'list[pyarrow]' " + f"dtype, not {parent.dtype}." ) super().__init__(parent=parent) diff --git a/python/cudf/cudf/core/accessors/string.py b/python/cudf/cudf/core/accessors/string.py index bf2d33dd777b..71a7f73f4caa 100644 --- a/python/cudf/cudf/core/accessors/string.py +++ b/python/cudf/cudf/core/accessors/string.py @@ -100,8 +100,23 @@ def __init__(self, parent: Series | Index): # Validate dtype is suitable for string operations is_valid_string = is_string_dtype(value_type) if not is_valid_string: + # Mirror pandas' inferred-type naming in the error message + # (see pandas lib.infer_dtype) so error messages match. + kind_to_inferred = { + "i": "integer", + "u": "integer", + "f": "floating", + "b": "boolean", + "c": "complex", + "M": "datetime64", + "m": "timedelta64", + } + inferred_dtype = kind_to_inferred.get( + getattr(value_type, "kind", ""), str(value_type) + ) raise AttributeError( - "Can only use .str accessor with string values" + "Can only use .str accessor with string values, " + f"not {inferred_dtype}" ) super().__init__(parent=parent) diff --git a/python/cudf/cudf/core/accessors/struct.py b/python/cudf/cudf/core/accessors/struct.py index e83181dd16b5..767c72ab0cf1 100644 --- a/python/cudf/cudf/core/accessors/struct.py +++ b/python/cudf/cudf/core/accessors/struct.py @@ -1,4 +1,4 @@ -# 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 from __future__ import annotations @@ -25,7 +25,8 @@ class StructMethods(BaseAccessor): def __init__(self, parent: Series | Index): if not is_dtype_obj_struct(parent.dtype): raise AttributeError( - "Can only use .struct accessor with a 'struct' dtype" + "Can only use the '.struct' accessor with 'struct[pyarrow]' " + f"dtype, not {parent.dtype}." ) super().__init__(parent=parent) diff --git a/python/cudf/cudf/core/column/datetime.py b/python/cudf/cudf/core/column/datetime.py index 25d7e079538f..d6b1a2da1c7c 100644 --- a/python/cudf/cudf/core/column/datetime.py +++ b/python/cudf/cudf/core/column/datetime.py @@ -511,7 +511,7 @@ def isocalendar(self) -> dict[str, ColumnBase]: # pandas' isocalendar for datetime64 returns UInt32 with NA preserved. is_arrow = isinstance(self.dtype, pd.ArrowDtype) target_dtype: DtypeObj = ( - np.dtype(np.int64) if is_arrow else np.dtype(np.uint32) + np.dtype(np.int64) if is_arrow else pd.UInt32Dtype() ) result = { field: self.strftime( @@ -1001,6 +1001,20 @@ def tz_convert(self, tz: str | None) -> DatetimeColumn: class DatetimeTZColumn(DatetimeColumn): + def _round_dt( + self, + round_func: Callable[ + [plc.Column, plc.datetime.RoundingFrequency], plc.Column + ], + freq: str, + ) -> ColumnBase: + # Rounding must operate on the local wall-clock time and then be + # re-localized to the original timezone to match pandas semantics. + rounded_local = cast( + "DatetimeColumn", self._local_time._round_dt(round_func, freq) + ) + return rounded_local.tz_localize(str(self.tz)) + def to_pandas( self, *, diff --git a/python/cudf/cudf/core/index.py b/python/cudf/cudf/core/index.py index 8590d52f8690..baa727cdbbd3 100644 --- a/python/cudf/cudf/core/index.py +++ b/python/cudf/cudf/core/index.py @@ -4018,8 +4018,14 @@ def microsecond(self) -> Index: >>> datetime_index.microsecond Index([0, 1, 2], dtype='int32') """ - # .microsecond is already a cached_property - return Index._from_column(self._column.microsecond, name=self.name) + # libcudf's MICROSECOND component excludes the millisecond + # component, but pandas folds milliseconds into microsecond. Match + # pandas (and Series.dt.microsecond) by adding millisecond * 1000. + micro = self._column.microsecond + extra = self._column.millisecond.astype(np.dtype(np.int32)) * np.int32( + 1000 + ) + return Index._from_column(micro + extra, name=self.name) @property @_performance_tracking diff --git a/python/cudf/cudf/core/series.py b/python/cudf/cudf/core/series.py index a222185ff55b..d071422b2ebb 100644 --- a/python/cudf/cudf/core/series.py +++ b/python/cudf/cudf/core/series.py @@ -3997,8 +3997,21 @@ class BaseDatelikeProperties: Base accessor class for Series values. """ + # Set via object.__setattr__ in __init__ (see __setattr__ below), so + # declare them here for type checkers. + series: Series + _frozen: bool + def __init__(self, series: Series): - self.series = series + object.__setattr__(self, "series", series) + object.__setattr__(self, "_frozen", True) + + def __setattr__(self, key, value): + # Mimic pandas' NoNewAttributesMixin: once initialized, disallow + # setting attributes that do not already exist. + if getattr(self, "_frozen", False) and not hasattr(self, key): + raise AttributeError(f"You cannot add any new attribute '{key}'") + object.__setattr__(self, key, value) def _return_result_like_self(self, column: ColumnBase) -> Series: """Return the method result like self.series""" @@ -4366,10 +4379,14 @@ def dayofweek(self) -> Series: dtype: int16 """ res = self.series._column.weekday - # Pandas returns int64 for weekday - res = res.astype( - get_dtype_of_same_kind(self.series.dtype, np.dtype("int64")) + # Pandas returns int32 for numpy-backed dayofweek/day_of_week but + # int64 for the pyarrow-backed (ArrowDtype) variant. + target = ( + np.dtype("int64") + if isinstance(self.series.dtype, pd.ArrowDtype) + else np.dtype("int32") ) + res = res.astype(get_dtype_of_same_kind(self.series.dtype, target)) return self._return_result_like_self(res) day_of_week = dayofweek @@ -5300,7 +5317,10 @@ def seconds(self) -> Series: 4 234000 dtype: int64 """ - return self._return_result_like_self(self.series._column.seconds) + res = self.series._column.seconds.astype( + get_dtype_of_same_kind(self.series.dtype, np.dtype("int32")) + ) + return self._return_result_like_self(res) @property @_performance_tracking @@ -5332,7 +5352,10 @@ def microseconds(self) -> Series: 4 234000 dtype: int64 """ - return self._return_result_like_self(self.series._column.microseconds) + res = self.series._column.microseconds.astype( + get_dtype_of_same_kind(self.series.dtype, np.dtype("int32")) + ) + return self._return_result_like_self(res) @property @_performance_tracking @@ -5364,7 +5387,10 @@ def nanoseconds(self) -> Series: 4 234 dtype: int64 """ - return self._return_result_like_self(self.series._column.nanoseconds) + res = self.series._column.nanoseconds.astype( + get_dtype_of_same_kind(self.series.dtype, np.dtype("int32")) + ) + return self._return_result_like_self(res) @property @_performance_tracking diff --git a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py index d42a89acdec8..412176a4815f 100644 --- a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py +++ b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py @@ -3867,25 +3867,11 @@ def pytest_unconfigure(config): "tests/scalar/timestamp/test_timestamp.py::TestTimestampProperties::test_dow_historic[0000-2-29-1]": "TODO: Add a reason for failure", "tests/scalar/timestamp/test_timestamp.py::TestTimestampProperties::test_dow_historic[0000-3-1-2]": "TODO: Add a reason for failure", "tests/scalar/timestamp/test_timestamp.py::test_negative_dates": "TODO: Add a reason for failure", - "tests/series/accessors/test_cat_accessor.py::TestCatAccessor::test_categorical_delegations": "TODO: Add a reason for failure", "tests/series/accessors/test_cat_accessor.py::TestCatAccessor::test_dt_accessor_api_for_categorical[idx0]": "TODO: Add a reason for failure", "tests/series/accessors/test_cat_accessor.py::TestCatAccessor::test_dt_accessor_api_for_categorical[idx1]": "AssertionError: Series are different", - "tests/series/accessors/test_cat_accessor.py::TestCatAccessor::test_dt_accessor_api_for_categorical[idx3]": "TODO: Add a reason for failure", "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_date_tz": "TODO: Add a reason for failure", "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_accessor_limited_display_api": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_accessor_no_new_attributes": "TODO: Add a reason for failure", "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_accessor_updates_on_inplace": "assert 0 2018-01-01\n1 2018-01-02\n2 2018-01-01\n3 2018-01-04\n4 2018-01-05\n5 2018-01-06\n6 2018-01-07...", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_namespace_accessor_datetime64[D]": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_namespace_accessor_datetime64[ms]": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_namespace_accessor_datetime64[s]": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_namespace_accessor_datetime64tz": "AssertionError: Attributes of Series are different", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_namespace_accessor_timedelta": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_dt_round_tz": "AssertionError: Series are different", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_isocalendar[input_series0-expected_output0]": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_isocalendar[input_series1-expected_output1]": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_isocalendar[input_series2-expected_output2]": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_isocalendar[input_series4-expected_output4]": "TODO: Add a reason for failure", - "tests/series/accessors/test_dt_accessor.py::TestSeriesDatetimeValues::test_isocalendar[input_series5-expected_output5]": "TODO: Add a reason for failure", "tests/series/accessors/test_list_accessor.py::test_list_getitem_invalid_index[list_dtype0]": "TODO: Add a reason for failure", "tests/series/indexing/test_datetime.py::test_indexing_over_size_cutoff_period_index": "TODO: Add a reason for failure", "tests/series/indexing/test_datetime.py::test_indexing_unordered": "TODO: Add a reason for failure", diff --git a/python/cudf/cudf/tests/indexes/datetimeindex/test_attributes.py b/python/cudf/cudf/tests/indexes/datetimeindex/test_attributes.py index f948db635a3e..e2daef3969ac 100644 --- a/python/cudf/cudf/tests/indexes/datetimeindex/test_attributes.py +++ b/python/cudf/cudf/tests/indexes/datetimeindex/test_attributes.py @@ -1,4 +1,4 @@ -# 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 @@ -73,6 +73,25 @@ def test_dt_index(field): assert_eq(getattr(gdf_data, field), getattr(data, field), exact=False) +def test_dti_microsecond_includes_milliseconds(): + # DatetimeIndex.microsecond must include the whole-millisecond component + # like pandas: 0.001002s -> 1002 microseconds (not 2). Values >= 1000 + # exercise the millisecond fold that plain sub-millisecond data misses. + data = [ + "2010-01-01 00:00:00.001002", + "2010-01-01 00:00:00.123456", + "2010-01-01 00:00:00.000999", + "2010-01-01 00:00:00.000000", + ] + pidx = pd.DatetimeIndex(data) + gidx = cudf.from_pandas(pidx) + + expect = pidx.microsecond + got = gidx.microsecond + assert_eq(expect, got) + assert got.dtype == np.dtype("int32") + + @pytest.mark.parametrize( "attr", [ diff --git a/python/cudf/cudf/tests/series/accessors/test_cat.py b/python/cudf/cudf/tests/series/accessors/test_cat.py index 8c0c253626b4..04ed4be29974 100644 --- a/python/cudf/cudf/tests/series/accessors/test_cat.py +++ b/python/cudf/cudf/tests/series/accessors/test_cat.py @@ -1,4 +1,4 @@ -# 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 from textwrap import dedent @@ -32,6 +32,22 @@ def test_categorical_basic(): assert str(sr) == str(pdsr) +@pytest.mark.parametrize("num_categories", [1, 3, 200, 40000]) +def test_cat_codes_signed_dtype(num_categories): + # cat.codes must use a signed integer dtype matching pandas (which widens + # int8 -> int16 -> int32 as the category count grows), not an unsigned one. + categories = [f"c{i}" for i in range(num_categories)] + ps = pd.Series(pd.Categorical(categories, categories=categories)) + gs = cudf.from_pandas(ps) + + expect = ps.cat.codes + got = gs.cat.codes + + assert got.dtype.kind == "i" + assert got.dtype == expect.dtype + assert_eq(expect, got) + + def test_categorical_integer(): cat = pd.Categorical.from_codes( [0, -1, -1, 2, 0], categories=["a", "b", "c"] @@ -351,8 +367,10 @@ def test_add_categories_mixed_error(): def test_categorical_allow_nan(): gs = cudf.Series([1, 2, np.nan, 10, np.nan, None], nan_as_null=False) gs = gs.astype("category") - expected_codes = cudf.Series([0, 1, None, 2, None, None], dtype="uint8") + # cat.codes uses a signed dtype to match pandas (int8 here, not uint8). + expected_codes = cudf.Series([0, 1, None, 2, None, None], dtype="int8") assert_eq(expected_codes, gs.cat.codes) + assert gs.cat.codes.dtype == np.dtype("int8") expected_categories = cudf.Index([1.0, 2.0, 10.0], dtype="float64") assert_eq(expected_categories, gs.cat.categories) diff --git a/python/cudf/cudf/tests/series/accessors/test_dt.py b/python/cudf/cudf/tests/series/accessors/test_dt.py index 15338cb4078b..f365218d6a85 100644 --- a/python/cudf/cudf/tests/series/accessors/test_dt.py +++ b/python/cudf/cudf/tests/series/accessors/test_dt.py @@ -1,4 +1,4 @@ -# 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 datetime import zoneinfo @@ -551,6 +551,65 @@ def test_isocalendar_series(data): assert_eq(expect, got, check_dtype=False) +@pytest.mark.parametrize("dtype", _DT_DTYPES) +def test_isocalendar_dtype(dtype): + # isocalendar's year/week/day columns must match pandas' backing dtype: + # UInt32 for numpy datetime, int64[pyarrow] for ArrowDtype timestamps. + ps = pd.Series(["2010-01-01", "2010-12-31", None], dtype=dtype) + gs = cudf.from_pandas(ps) + + expect = ps.dt.isocalendar() + got = gs.dt.isocalendar() + + # Exercise both value equality and the exact per-column dtype. + assert_eq(expect, got) + assert dict(got.dtypes) == dict(expect.dtypes) + if isinstance(dtype, pd.ArrowDtype): + assert all(dt == pd.ArrowDtype(pa.int64()) for dt in got.dtypes) + else: + assert all(dt == pd.UInt32Dtype() for dt in got.dtypes) + + +@pytest.mark.parametrize("dtype", _DT_DTYPES) +@pytest.mark.parametrize("attr", ["dayofweek", "day_of_week", "weekday"]) +def test_dayofweek_dtype(dtype, attr): + # dayofweek/day_of_week/weekday are aliases. pandas returns int32 for the + # numpy-backed variant but int64[pyarrow] for the ArrowDtype variant. + ps = pd.Series(["2010-01-01", "2010-12-31", "2011-06-15"], dtype=dtype) + gs = cudf.from_pandas(ps) + + expect = getattr(ps.dt, attr) + got = getattr(gs.dt, attr) + + assert_eq(expect, got) + if isinstance(dtype, pd.ArrowDtype): + assert got.dtype == pd.ArrowDtype(pa.int64()) + assert expect.dtype == pd.ArrowDtype(pa.int64()) + else: + assert got.dtype == np.dtype("int32") + assert expect.dtype == np.dtype("int32") + + +def test_timedelta_components_dtype(): + # pandas returns int64 for .dt.days but int32 for the sub-day components + # (seconds/microseconds/nanoseconds); cudf must match. + ps = pd.Series([1000000, 200000, 3000000], dtype="timedelta64[ns]") + gs = cudf.from_pandas(ps) + + for attr, expected_dtype in [ + ("days", np.dtype("int64")), + ("seconds", np.dtype("int32")), + ("microseconds", np.dtype("int32")), + ("nanoseconds", np.dtype("int32")), + ]: + expect = getattr(ps.dt, attr) + got = getattr(gs.dt, attr) + assert_eq(expect, got) + assert got.dtype == expected_dtype + # Sanity: our expectation tracks pandas' actual dtype. + assert expect.dtype == expected_dtype + + @pytest.mark.parametrize( "data", [[1, 2, 3, None], [], [100121, 1221312, 321312321, 1232131223]] ) diff --git a/python/cudf/cudf/tests/series/accessors/test_list.py b/python/cudf/cudf/tests/series/accessors/test_list.py index b04b24107362..4a0111d3eb69 100644 --- a/python/cudf/cudf/tests/series/accessors/test_list.py +++ b/python/cudf/cudf/tests/series/accessors/test_list.py @@ -3,6 +3,7 @@ import functools import operator +import re import numpy as np import pandas as pd @@ -560,3 +561,17 @@ def test_lists_contains_bool(): assert data.list.contains(True)[0] assert not data.list.contains(False)[0] + + +def test_list_accessor_invalid_dtype_message(): + # The .list accessor error message names the required 'list[pyarrow]' + # dtype and reports the offending dtype, matching pandas. + gs = cudf.Series([1, 2, 3]) + with pytest.raises( + AttributeError, + match=re.escape( + "Can only use the '.list' accessor with 'list[pyarrow]' dtype, " + "not int64." + ), + ): + gs.list diff --git a/python/cudf/cudf/tests/series/accessors/test_str.py b/python/cudf/cudf/tests/series/accessors/test_str.py index 91993005e4af..5b1975135870 100644 --- a/python/cudf/cudf/tests/series/accessors/test_str.py +++ b/python/cudf/cudf/tests/series/accessors/test_str.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd +import pyarrow as pa import pytest import cudf @@ -2987,3 +2988,33 @@ def test_string_list_get_access(): got = got.str.get(1) assert_eq(expect, got) + + +@pytest.mark.parametrize( + "data,dtype,inferred", + [ + ([1, 2, 3], "int64", "integer"), + ([1, 2, 3], "uint32", "integer"), + ([1, 2, 3], pd.ArrowDtype(pa.int64()), "integer"), + ([1, 2, 3], pd.Int64Dtype(), "integer"), + ([1.0, 2.0, 3.0], "float64", "floating"), + ([True, False, True], "bool", "boolean"), + (pd.to_datetime(["2020-01-01", "2021-06-15"]), None, "datetime64"), + (pd.to_timedelta([1, 2, 3], unit="s"), None, "timedelta64"), + ], +) +def test_str_accessor_invalid_dtype_message(data, dtype, inferred): + # The .str accessor mirrors pandas' inferred-type naming in its error + # message (integer/floating/boolean/datetime64/timedelta64) across numpy, + # pandas-nullable and arrow-backed dtypes. + if dtype is not None: + gs = cudf.Series(data, dtype=dtype) + else: + gs = cudf.Series(data) + with pytest.raises( + AttributeError, + match=re.escape( + f"Can only use .str accessor with string values, not {inferred}" + ), + ): + gs.str diff --git a/python/cudf/cudf/tests/series/accessors/test_struct.py b/python/cudf/cudf/tests/series/accessors/test_struct.py index abf7f0af4c4e..2876f6cb93ca 100644 --- a/python/cudf/cudf/tests/series/accessors/test_struct.py +++ b/python/cudf/cudf/tests/series/accessors/test_struct.py @@ -1,6 +1,8 @@ -# 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 re + import pytest import cudf @@ -73,3 +75,17 @@ def test_struct_for_field(key, expect, expect_name): expect = cudf.Series(expect, name=expect_name) got = sr.struct.field(key) assert_eq(expect, got) + + +def test_struct_accessor_invalid_dtype_message(): + # The .struct accessor error message names the required 'struct[pyarrow]' + # dtype and reports the offending dtype, matching pandas. + gs = cudf.Series([1, 2, 3]) + with pytest.raises( + AttributeError, + match=re.escape( + "Can only use the '.struct' accessor with 'struct[pyarrow]' " + "dtype, not int64." + ), + ): + gs.struct From 0684181b70af5083a778acf5f6d1a1c36c743145 Mon Sep 17 00:00:00 2001 From: galipremsagar Date: Wed, 1 Jul 2026 22:39:35 +0000 Subject: [PATCH 2/3] fix --- .../cudf/cudf/core/accessors/categorical.py | 18 +++++++++++---- .../pandas/scripts/pandas-testing-plugin.py | 2 -- .../cudf/tests/series/methods/test_astype.py | 23 +++++++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/python/cudf/cudf/core/accessors/categorical.py b/python/cudf/cudf/core/accessors/categorical.py index f0ee777c75a8..9327d26c6ddc 100644 --- a/python/cudf/cudf/core/accessors/categorical.py +++ b/python/cudf/cudf/core/accessors/categorical.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Any +import numpy as np + from cudf.core.accessors.base_accessor import BaseAccessor from cudf.core.dtypes import CategoricalDtype @@ -90,14 +92,22 @@ def codes(self) -> Series: Return Series of codes as well as the index. """ from cudf.core.series import Series - from cudf.utils.dtypes import min_signed_type index = ( self._parent.index if isinstance(self._parent, Series) else None ) - codes = self._column.codes.astype( - min_signed_type(len(self._column.dtype.categories)) - ) + # Match pandas' categorical code dtype: a signed integer that widens + # int8 -> int16 -> int32 -> int64 as the category count grows. pandas + # switches once the count reaches the next type's max (see + # pandas.core.arrays.categorical.coerce_indexer_dtype), i.e. int8 is + # used only for fewer than ``np.iinfo(np.int8).max`` (127) categories. + n_categories = len(self._column.dtype.categories) + code_dtype: np.dtype[Any] = np.dtype(np.int64) + for candidate in (np.int8, np.int16, np.int32): + if n_categories < np.iinfo(candidate).max: + code_dtype = np.dtype(candidate) + break + codes = self._column.codes.astype(code_dtype) return Series._from_column(codes, index=index) @property diff --git a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py index 412176a4815f..354f825e37ea 100644 --- a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py +++ b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py @@ -2387,8 +2387,6 @@ def pytest_unconfigure(config): "tests/indexes/datetimes/methods/test_factorize.py::TestDatetimeIndexFactorize::test_factorize_tz['US/Eastern'-series]": "AssertionError: numpy array are different", "tests/indexes/datetimes/methods/test_insert.py::TestInsert::test_insert_empty_preserves_freq[None]": "AssertionError: assert is None", "tests/indexes/datetimes/methods/test_isocalendar.py::test_dti_timestamp_isocalendar_fields": "TODO: Add a reason for failure", - "tests/indexes/datetimes/methods/test_isocalendar.py::test_isocalendar_returns_correct_values_close_to_new_year_with_tz": "AssertionError: Attributes of DataFrame.iloc[:, 0] (column name='year') are different", - "tests/indexes/datetimes/methods/test_round.py::TestDatetimeIndexRound::test_round_daily": "TODO: Add a reason for failure", "tests/indexes/datetimes/methods/test_shift.py::TestDatetimeIndexShift::test_dti_shift_across_dst[ms]": "AssertionError: Series.index are different", "tests/indexes/datetimes/methods/test_shift.py::TestDatetimeIndexShift::test_dti_shift_across_dst[ns]": "AssertionError: Series.index are different", "tests/indexes/datetimes/methods/test_shift.py::TestDatetimeIndexShift::test_dti_shift_across_dst[s]": "AssertionError: Series.index are different", diff --git a/python/cudf/cudf/tests/series/methods/test_astype.py b/python/cudf/cudf/tests/series/methods/test_astype.py index 13757fb2b74e..f16896296d9b 100644 --- a/python/cudf/cudf/tests/series/methods/test_astype.py +++ b/python/cudf/cudf/tests/series/methods/test_astype.py @@ -1509,16 +1509,19 @@ def test_categorical_typecast(data, categories): @pytest.mark.parametrize( ("values", "expected"), [ - ([1], np.uint8), - ([1, None], np.uint8), - (np.arange(np.iinfo(np.int8).max), np.uint8), - (np.append(np.arange(np.iinfo(np.int8).max), [None]), np.uint8), - (np.arange(np.iinfo(np.int16).max), np.uint16), - (np.append(np.arange(np.iinfo(np.int16).max), [None]), np.uint16), - (np.arange(np.iinfo(np.uint8).max), np.uint8), - (np.append(np.arange(np.iinfo(np.uint8).max), [None]), np.uint8), - (np.arange(np.iinfo(np.uint16).max), np.uint16), - (np.append(np.arange(np.iinfo(np.uint16).max), [None]), np.uint16), + # cat.codes uses a signed dtype matching pandas (which widens + # int8 -> int16 -> int32 once the category count reaches each type's + # max), so e.g. 127 categories -> int16 and 32767 categories -> int32. + ([1], np.int8), + ([1, None], np.int8), + (np.arange(np.iinfo(np.int8).max), np.int16), + (np.append(np.arange(np.iinfo(np.int8).max), [None]), np.int16), + (np.arange(np.iinfo(np.int16).max), np.int32), + (np.append(np.arange(np.iinfo(np.int16).max), [None]), np.int32), + (np.arange(np.iinfo(np.uint8).max), np.int16), + (np.append(np.arange(np.iinfo(np.uint8).max), [None]), np.int16), + (np.arange(np.iinfo(np.uint16).max), np.int32), + (np.append(np.arange(np.iinfo(np.uint16).max), [None]), np.int32), ], ) def test_astype_dtype(values, expected): From 7a07cb26ece0aee90c2fd80274c84945283f31c7 Mon Sep 17 00:00:00 2001 From: galipremsagar Date: Fri, 3 Jul 2026 03:21:07 +0000 Subject: [PATCH 3/3] address reviews --- .../cudf/cudf/core/accessors/categorical.py | 17 ++------ python/cudf/cudf/core/column/categorical.py | 3 +- python/cudf/cudf/core/dtypes.py | 21 +++++++++- python/cudf/cudf/core/mixins/__init__.py | 4 +- .../cudf/core/mixins/no_new_attributes.py | 42 +++++++++++++++++++ python/cudf/cudf/core/series.py | 19 ++------- .../cudf/tests/series/accessors/test_dt.py | 29 +++++++------ 7 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 python/cudf/cudf/core/mixins/no_new_attributes.py diff --git a/python/cudf/cudf/core/accessors/categorical.py b/python/cudf/cudf/core/accessors/categorical.py index 9327d26c6ddc..cb88b4515056 100644 --- a/python/cudf/cudf/core/accessors/categorical.py +++ b/python/cudf/cudf/core/accessors/categorical.py @@ -5,8 +5,6 @@ from typing import TYPE_CHECKING, Any -import numpy as np - from cudf.core.accessors.base_accessor import BaseAccessor from cudf.core.dtypes import CategoricalDtype @@ -96,18 +94,9 @@ def codes(self) -> Series: index = ( self._parent.index if isinstance(self._parent, Series) else None ) - # Match pandas' categorical code dtype: a signed integer that widens - # int8 -> int16 -> int32 -> int64 as the category count grows. pandas - # switches once the count reaches the next type's max (see - # pandas.core.arrays.categorical.coerce_indexer_dtype), i.e. int8 is - # used only for fewer than ``np.iinfo(np.int8).max`` (127) categories. - n_categories = len(self._column.dtype.categories) - code_dtype: np.dtype[Any] = np.dtype(np.int64) - for candidate in (np.int8, np.int16, np.int32): - if n_categories < np.iinfo(candidate).max: - code_dtype = np.dtype(candidate) - break - codes = self._column.codes.astype(code_dtype) + codes = self._column.codes.astype( + self._column.dtype._pandas_codes_dtype + ) return Series._from_column(codes, index=index) @property diff --git a/python/cudf/cudf/core/column/categorical.py b/python/cudf/cudf/core/column/categorical.py index 7cc919c822f9..4e4ef85803de 100644 --- a/python/cudf/cudf/core/column/categorical.py +++ b/python/cudf/cudf/core/column/categorical.py @@ -303,9 +303,8 @@ def to_pandas( if arrow_type: raise NotImplementedError(f"{arrow_type=} is not supported.") - signed_dtype = min_signed_type(len(self.categories)) codes = ( - self.codes.astype(signed_dtype) + self.codes.astype(self.dtype._pandas_codes_dtype) .fillna(_DEFAULT_CATEGORICAL_VALUE) .to_numpy() ) diff --git a/python/cudf/cudf/core/dtypes.py b/python/cudf/cudf/core/dtypes.py index 47585d46c46e..fa0f142720b1 100644 --- a/python/cudf/cudf/core/dtypes.py +++ b/python/cudf/cudf/core/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 @@ -250,6 +250,25 @@ def _codes_dtype(self) -> np.dtype: """Return the dtype used for categorical codes.""" return min_unsigned_type(len(self.categories)) + @cached_property + def _pandas_codes_dtype(self) -> np.dtype: + """The signed dtype pandas uses for these categorical codes. + + pandas coerces codes when constructing categorical data (see + ``pandas.core.arrays.categorical.coerce_indexer_dtype``): a signed + integer that widens int8 -> int16 -> int32 -> int64 once the + category count reaches the narrower type's max (int8 is used only + for *fewer* than ``np.iinfo(np.int8).max`` (127) categories). cudf + stores codes as the unsigned ``_codes_dtype`` (a zero-copy view of + the underlying dictionary indices), so pandas-facing code paths + cast to this dtype instead. + """ + n_categories = len(self.categories) + for candidate in (np.int8, np.int16, np.int32): + if n_categories < np.iinfo(candidate).max: + return np.dtype(candidate) + return np.dtype(np.int64) + def to_pandas(self) -> pd.CategoricalDtype: """ Convert a ``cudf.CategoricalDtype`` to ``pandas.CategoricalDtype`` diff --git a/python/cudf/cudf/core/mixins/__init__.py b/python/cudf/cudf/core/mixins/__init__.py index 0493a16f7a83..25445ed75ce9 100644 --- a/python/cudf/cudf/core/mixins/__init__.py +++ b/python/cudf/cudf/core/mixins/__init__.py @@ -1,8 +1,9 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from .binops import BinaryOperand from .getitem import GetAttrGetItemMixin +from .no_new_attributes import NoNewAttributesMixin from .notiterable import NotIterable from .reductions import Reducible from .scans import Scannable @@ -10,6 +11,7 @@ __all__ = [ "BinaryOperand", "GetAttrGetItemMixin", + "NoNewAttributesMixin", "NotIterable", "Reducible", "Scannable", diff --git a/python/cudf/cudf/core/mixins/no_new_attributes.py b/python/cudf/cudf/core/mixins/no_new_attributes.py new file mode 100644 index 000000000000..b33742a4c00d --- /dev/null +++ b/python/cudf/cudf/core/mixins/no_new_attributes.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + + +class NoNewAttributesMixin: + """ + Mixin which prevents adding new attributes. + + Vendored from ``pandas.core.base.NoNewAttributesMixin``. + + Prevents additional attributes via ``xxx.attribute = "something"`` after + a call to ``self._freeze()``. Mainly used to prevent the user from using + wrong attributes on an accessor (`Series.cat/.str/.dt`). + + If you really want to add a new attribute at a later time, you need to + use ``object.__setattr__(self, key, value)``. + """ + + def _freeze(self) -> None: + """ + Prevents setting additional attributes. + """ + object.__setattr__(self, "__frozen", True) + + # prevent adding any attribute via s.xxx.new_attribute = ... + def __setattr__(self, key: str, value: Any) -> None: + # _cache is used by a decorator + # We need to check both 1.) cls.__dict__ and 2.) getattr(self, key) + # because + # 1.) getattr is false for attributes that raise errors + # 2.) cls.__dict__ doesn't traverse into base classes + if getattr(self, "__frozen", False) and not ( + key == "_cache" + or key in type(self).__dict__ + or getattr(self, key, None) is not None + ): + raise AttributeError(f"You cannot add any new attribute '{key}'") + object.__setattr__(self, key, value) diff --git a/python/cudf/cudf/core/series.py b/python/cudf/cudf/core/series.py index da963589780e..2ee87f9ef57a 100644 --- a/python/cudf/cudf/core/series.py +++ b/python/cudf/cudf/core/series.py @@ -57,6 +57,7 @@ _indices_from_labels, doc_reset_index_template, ) +from cudf.core.mixins import NoNewAttributesMixin from cudf.core.resample import SeriesResampler from cudf.core.single_column_frame import SingleColumnFrame from cudf.core.udf.scalar_function import SeriesApplyKernel @@ -3989,26 +3990,14 @@ def wrapper(self, other, level=None, fill_value=None, axis=0): setattr(Series, binop, make_binop_func(binop)) -class BaseDatelikeProperties: +class BaseDatelikeProperties(NoNewAttributesMixin): """ Base accessor class for Series values. """ - # Set via object.__setattr__ in __init__ (see __setattr__ below), so - # declare them here for type checkers. - series: Series - _frozen: bool - def __init__(self, series: Series): - object.__setattr__(self, "series", series) - object.__setattr__(self, "_frozen", True) - - def __setattr__(self, key, value): - # Mimic pandas' NoNewAttributesMixin: once initialized, disallow - # setting attributes that do not already exist. - if getattr(self, "_frozen", False) and not hasattr(self, key): - raise AttributeError(f"You cannot add any new attribute '{key}'") - object.__setattr__(self, key, value) + self.series = series + self._freeze() def _return_result_like_self(self, column: ColumnBase) -> Series: """Return the method result like self.series""" diff --git a/python/cudf/cudf/tests/series/accessors/test_dt.py b/python/cudf/cudf/tests/series/accessors/test_dt.py index f365218d6a85..ba4fc2ffe22e 100644 --- a/python/cudf/cudf/tests/series/accessors/test_dt.py +++ b/python/cudf/cudf/tests/series/accessors/test_dt.py @@ -590,24 +590,27 @@ def test_dayofweek_dtype(dtype, attr): assert expect.dtype == np.dtype("int32") -def test_timedelta_components_dtype(): +@pytest.mark.parametrize( + "attr, expected_dtype", + [ + ("days", np.dtype("int64")), + ("seconds", np.dtype("int32")), + ("microseconds", np.dtype("int32")), + ("nanoseconds", np.dtype("int32")), + ], +) +def test_timedelta_components_dtype(attr, expected_dtype): # pandas returns int64 for .dt.days but int32 for the sub-day components # (seconds/microseconds/nanoseconds); cudf must match. ps = pd.Series([1000000, 200000, 3000000], dtype="timedelta64[ns]") gs = cudf.from_pandas(ps) - for attr, expected_dtype in [ - ("days", np.dtype("int64")), - ("seconds", np.dtype("int32")), - ("microseconds", np.dtype("int32")), - ("nanoseconds", np.dtype("int32")), - ]: - expect = getattr(ps.dt, attr) - got = getattr(gs.dt, attr) - assert_eq(expect, got) - assert got.dtype == expected_dtype - # Sanity: our expectation tracks pandas' actual dtype. - assert expect.dtype == expected_dtype + expect = getattr(ps.dt, attr) + got = getattr(gs.dt, attr) + assert_eq(expect, got) + assert got.dtype == expected_dtype + # Sanity: our expectation tracks pandas' actual dtype. + assert expect.dtype == expected_dtype @pytest.mark.parametrize(