Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
405b61f
tests
brandon-b-miller Mar 1, 2025
36eb333
initial
brandon-b-miller Mar 1, 2025
7eaaab2
merge/resolve
brandon-b-miller Mar 10, 2025
a60d8e1
merge/resolve
brandon-b-miller Mar 13, 2025
d4d8748
fix up
brandon-b-miller Mar 19, 2025
547ba6c
slightly adjust logic
brandon-b-miller Mar 19, 2025
327e427
merge/resolve
brandon-b-miller Mar 21, 2025
b994c6b
Merge branch 'branch-25.06' into fea-cudf-polars-more-dt-extracts
brandon-b-miller Apr 1, 2025
5065648
clean
brandon-b-miller Apr 1, 2025
bd55421
remove unused mapping
brandon-b-miller Apr 1, 2025
3985014
something partial
brandon-b-miller Apr 1, 2025
a8c13b0
Merge branch 'branch-25.06' into fea-cudf-polars-more-dt-extracts
brandon-b-miller Apr 2, 2025
5a22a24
significantly reduce logic
brandon-b-miller Apr 2, 2025
4b1ee24
clean
brandon-b-miller Apr 2, 2025
1711b61
merge/resolve
brandon-b-miller Apr 3, 2025
fcfd75e
failing tests
brandon-b-miller Apr 3, 2025
bd573b7
merge/resolve/pass
brandon-b-miller Jun 22, 2026
0ce3e8e
clean
brandon-b-miller Jun 22, 2026
65e527a
Merge branch 'main' into fea-cudf-polars-more-dt-extracts
brandon-b-miller Jun 22, 2026
1fb02e7
Merge branch 'main' into fea-cudf-polars-more-dt-extracts
vyasr Jun 22, 2026
0f8d495
address reviews
brandon-b-miller Jun 22, 2026
3a8a4e2
shortcut in astype
brandon-b-miller Jun 23, 2026
5912548
Merge branch 'main' into fea-cudf-polars-more-dt-extracts
brandon-b-miller Jun 23, 2026
7a25dbb
coverage
brandon-b-miller Jun 23, 2026
3d55b64
merge/resolve
brandon-b-miller Jun 24, 2026
55995e7
Merge branch 'main' into fea-cudf-polars-more-dt-extracts
brandon-b-miller Jun 24, 2026
bfbfbe3
Merge branch 'main' into fea-cudf-polars-more-dt-extracts
brandon-b-miller Jun 25, 2026
a3e4b41
Merge branch 'main' into fea-cudf-polars-more-dt-extracts
brandon-b-miller Jun 25, 2026
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
25 changes: 24 additions & 1 deletion python/cudf_polars/cudf_polars/containers/column.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""A column, with some properties."""
Expand Down Expand Up @@ -353,6 +353,29 @@ def astype(self, dtype: DataType, stream: Stream, *, strict: bool = True) -> Col
dtype=dtype,
name=self.name,
).sorted_like(self)
elif plc.traits.is_integral_not_bool(plc_dtype) and plc.traits.is_duration(
self.obj.type()
):
# A duration is stored as an integer tick count, so casting to that
# integer type is a no-op reinterpret of the same bytes. Relabel the
# column instead of launching a cast kernel.
rep = plc.DataType(
plc.TypeId.INT32
if self.obj.type().id() == plc.TypeId.DURATION_DAYS
else plc.TypeId.INT64
)
plc_col = plc.column.Column(
rep,
self.obj.size(),
self.obj.data(),
self.obj.null_mask(),
self.obj.null_count(),
self.obj.offset(),
self.obj.children(),
)
if rep.id() != plc_dtype.id():
plc_col = plc.unary.cast(plc_col, plc_dtype, stream=stream)
Comment thread
Matt711 marked this conversation as resolved.
return Column(plc_col, dtype=dtype, name=self.name).sorted_like(self)
elif plc.traits.is_floating_point(
self.obj.type()
) and plc.traits.is_fixed_point(plc_dtype):
Expand Down
49 changes: 48 additions & 1 deletion python/cudf_polars/cudf_polars/dsl/expressions/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@
__all__ = ["TemporalFunction"]


_unit_to_nanoseconds_conversion = {
plc.TypeId.DURATION_NANOSECONDS: 1,
plc.TypeId.DURATION_MICROSECONDS: 1_000,
plc.TypeId.DURATION_MILLISECONDS: 1_000_000,
plc.TypeId.DURATION_SECONDS: 1_000_000_000,
plc.TypeId.DURATION_DAYS: 86_400_000_000_000,
}
Comment thread
Matt711 marked this conversation as resolved.


class TemporalFunction(Expr):
class Name(IntEnum):
"""Internal and picklable representation of polars' `TemporalFunction`."""
Expand Down Expand Up @@ -114,6 +123,16 @@ def from_polars(cls, obj: polars._expr_nodes.TemporalFunction) -> Self:
"ns": plc.datetime.RoundingFrequency.NANOSECOND,
}

# Number of nanoseconds represented by one unit of each ``total_*`` component.
_TOTAL_COMPONENT_NANOSECONDS: ClassVar[dict[Name, int]] = {
Name.TotalDays: 86_400_000_000_000,
Name.TotalHours: 3_600_000_000_000,
Name.TotalMinutes: 60_000_000_000,
Name.TotalSeconds: 1_000_000_000,
Name.TotalMilliseconds: 1_000_000,
Name.TotalMicroseconds: 1_000,
Name.TotalNanoseconds: 1,
}
_valid_ops: ClassVar[set[Name]] = {
*_COMPONENT_MAP.keys(),
Name.IsLeapYear,
Expand All @@ -126,6 +145,7 @@ def from_polars(cls, obj: polars._expr_nodes.TemporalFunction) -> Self:
Name.TimeStamp,
Name.CastTimeUnit,
Name.Truncate,
*_TOTAL_COMPONENT_NANOSECONDS.keys(),
}

def __init__(
Expand Down Expand Up @@ -159,6 +179,34 @@ def do_evaluate(
) -> Column:
"""Evaluate this expression given a dataframe for context."""
columns = [child.evaluate(df, context=context) for child in self.children]
if self.name in self._TOTAL_COMPONENT_NANOSECONDS:
(column,) = columns
source_ns = _unit_to_nanoseconds_conversion[column.obj.type().id()]
target_ns = self._TOTAL_COMPONENT_NANOSECONDS[self.name]
# Reinterpret the duration's integer tick count as int64.
casted = column.astype(self.dtype, stream=df.stream)
if source_ns >= target_ns:
# Coarser (or equal) storage unit: exact integer multiply.
op = plc.binaryop.BinaryOperator.MUL
factor = source_ns // target_ns
else:
# Finer storage unit: integer divide. libcudf (like polars)
# truncates toward zero for signed integer division.
op = plc.binaryop.BinaryOperator.DIV
factor = target_ns // source_ns
if factor == 1:
# Storage unit already matches the requested unit.
return casted
result = plc.binaryop.binary_operation(
Comment thread
brandon-b-miller marked this conversation as resolved.
casted.obj,
plc.Scalar.from_py(
factor, plc.DataType(plc.TypeId.INT64), stream=df.stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: if factor is ever 1, we could avoid this plc.binaryop.binary_operation call all together and return Column(casted, dtype=self.dtype)

),
op,
self.dtype.plc_type,
stream=df.stream,
)
return Column(result, dtype=self.dtype)
if self.name is TemporalFunction.Name.TimeStamp:
(column,) = columns
(time_unit,) = self.options
Expand Down Expand Up @@ -257,7 +305,6 @@ def do_evaluate(
self.dtype.plc_type,
stream=df.stream,
)

return Column(result, dtype=self.dtype)
elif self.name is TemporalFunction.Name.MonthEnd:
(column,) = columns
Expand Down
20 changes: 19 additions & 1 deletion python/cudf_polars/tests/containers/test_column.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations
Expand Down Expand Up @@ -315,6 +315,24 @@ def test_astype_to_string(val, plc_tid, pl_type):
assert result.dtype == target_dtype


def test_astype_duration_to_narrower_integer():
stream = get_cuda_stream()
col = Column(
plc.unary.cast(
plc.Column.from_iterable_of_py(
[1, 2, -3], plc.DataType(plc.TypeId.INT64), stream=stream
),
plc.DataType(plc.TypeId.DURATION_MICROSECONDS),
stream=stream,
),
dtype=DataType(pl.Duration(time_unit="us")),
)
target_dtype = DataType(pl.Int32())
result = col.astype(target_dtype, stream=stream)
assert result.dtype == target_dtype
assert result.obj.type().id() == plc.TypeId.INT32
Comment on lines +318 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Test currently verifies metadata but not cast correctness of values

At Line 332–333, the test only checks dtype/type-id. Please also assert the converted values (and add at least one edge case like null/empty/single-element), otherwise value corruption/regressions in astype can pass unnoticed. Also, this test direction (duration→int) does not directly cover the new integral→duration path called out in this PR cohort.

As per coding guidelines: python/**/test_*.py: “Ensure test files provide comprehensive edge case coverage (empty, all-null, single-element, mixed types)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cudf_polars/tests/containers/test_column.py` around lines 318 - 333,
The test_astype_duration_to_narrower_integer function only validates the dtype
and type-id of the result but does not verify the actual converted values, which
means data corruption could go undetected. Add assertions that check the actual
values in result.obj match the expected converted output from the original input
[1, 2, -3]. Additionally, enhance the test with edge case coverage by adding
separate test cases or parameterization for empty columns, null/missing values,
and single-element columns to ensure robustness per the coding guidelines.
Consider also adding a complementary test that covers the integral-to-duration
conversion direction to provide complete bidirectional coverage for the astype
functionality.

Source: Coding guidelines



def test_astype_from_string_unsupported():
stream = get_cuda_stream()
col = Column(
Expand Down
42 changes: 42 additions & 0 deletions python/cudf_polars/tests/expressions/test_datetime_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ def test_datetime_dataframe_scan(engine: pl.GPUEngine, dtype):
"nanosecond",
]

duration_extract_fields = [
"total_seconds",
"total_milliseconds",
"total_microseconds",
"total_nanoseconds",
"total_days",
"total_hours",
"total_minutes",
]


@pytest.fixture(
ids=datetime_extract_fields,
Expand Down Expand Up @@ -171,6 +181,38 @@ def test_strftime_duration(engine: pl.GPUEngine, format):
assert_ir_translation_raises(q, engine, NotImplementedError)


@pytest.mark.parametrize("field", duration_extract_fields)
@pytest.mark.parametrize(
"dtype", [pl.Duration("ms"), pl.Duration("us"), pl.Duration("ns")]
)
def test_duration_total_component_extract(engine: pl.GPUEngine, field, dtype):
ldf = pl.LazyFrame(
{
"durations": pl.Series(
[
0,
1,
15,
-1500,
1000,
1111,
1500,
11111,
-134234534,
134234534,
# values beyond float64's exact-integer range to guard
# against precision loss in the unit conversion
5857593848682946,
-5857593848682946,
],
dtype=dtype,
),
}
)
q = ldf.select(getattr(pl.col("durations").dt, field)())
assert_gpu_result_equal(q, engine=engine)


@pytest.mark.parametrize(
"dtype", [pl.Date(), pl.Datetime("ms"), pl.Datetime("us"), pl.Datetime("ns")]
)
Expand Down
Loading