diff --git a/python/cudf/cudf/core/accessors/categorical.py b/python/cudf/cudf/core/accessors/categorical.py index 405eef35bb14..cb88b4515056 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 @@ -94,7 +94,10 @@ def codes(self) -> Series: 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( + self._column.dtype._pandas_codes_dtype + ) + 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/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/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/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/index.py b/python/cudf/cudf/core/index.py index 6904ba1ef78b..444a55face86 100644 --- a/python/cudf/cudf/core/index.py +++ b/python/cudf/cudf/core/index.py @@ -4017,8 +4017,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/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 f02a5b827558..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,13 +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. """ def __init__(self, series: Series): self.series = series + self._freeze() def _return_result_like_self(self, column: ColumnBase) -> Series: """Return the method result like self.series""" @@ -4363,10 +4365,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 @@ -5297,7 +5303,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 @@ -5329,7 +5338,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 @@ -5361,7 +5373,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 143937c6db82..afc0cb0c4418 100644 --- a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py +++ b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py @@ -2339,8 +2339,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", @@ -3799,25 +3797,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..ba4fc2ffe22e 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,68 @@ 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") + + +@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) + + 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 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):