Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions python/cudf/cudf/core/accessors/categorical.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

@property
def ordered(self) -> bool | None:
Expand Down
5 changes: 3 additions & 2 deletions python/cudf/cudf/core/accessors/lists.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)

Expand Down
17 changes: 16 additions & 1 deletion python/cudf/cudf/core/accessors/string.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions python/cudf/cudf/core/accessors/struct.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand Down
16 changes: 15 additions & 1 deletion python/cudf/cudf/core/column/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
*,
Expand Down
10 changes: 8 additions & 2 deletions python/cudf/cudf/core/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 33 additions & 7 deletions python/cudf/cudf/core/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'")
Comment thread
galipremsagar marked this conversation as resolved.
Resolved
object.__setattr__(self, key, value)

def _return_result_like_self(self, column: ColumnBase) -> Series:
"""Return the method result like self.series"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 0 additions & 14 deletions python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
[
Expand Down
22 changes: 20 additions & 2 deletions python/cudf/cudf/tests/series/accessors/test_cat.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 60 additions & 1 deletion python/cudf/cudf/tests/series/accessors/test_dt.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")),
]:
Comment thread
galipremsagar marked this conversation as resolved.
Resolved
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]]
)
Expand Down
Loading
Loading