Files
ray-project--ray/python/ray/data/tests/expressions/test_cast.py
T
2026-07-13 13:17:40 +08:00

193 lines
6.6 KiB
Python

import pandas as pd
import pyarrow as pa
import pytest
from packaging.version import parse as parse_version
import ray
from ray.data._internal.util import rows_same
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.datatype import DataType
from ray.data.exceptions import UserCodeException
from ray.data.expressions import col
from ray.data.tests.conftest import * # noqa
from ray.tests.conftest import * # noqa
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("20.0.0"),
reason="with_column requires PyArrow >= 20.0.0",
)
@pytest.mark.parametrize(
"expr, target_type, expected_rows",
[
# Basic type conversions using Ray Data's DataType
(col("id"), DataType.int64(), [{"id": i, "result": i} for i in range(5)]),
(
col("id"),
DataType.float64(),
[{"id": i, "result": float(i)} for i in range(5)],
),
(
col("id"),
DataType.string(),
[{"id": i, "result": str(i)} for i in range(5)],
),
(
col("id") / 2,
DataType.int64(),
[{"id": i, "result": i // 2} for i in range(5)],
),
# col("id")/2 uses integer division in expression layer, then cast to float64
(
col("id") / 2,
DataType.float64(),
[{"id": i, "result": float(i // 2)} for i in range(5)],
),
],
)
def test_cast_expression_basic(
ray_start_regular_shared,
expr,
target_type,
expected_rows,
target_max_block_size_infinite_or_default,
):
"""Test basic type casting with cast() method."""
ds = ray.data.range(5).with_column("result", expr.cast(target_type))
actual = ds.take_all()
assert rows_same(pd.DataFrame(actual), pd.DataFrame(expected_rows))
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("20.0.0"),
reason="with_column requires PyArrow >= 20.0.0",
)
def test_cast_expression_usecase(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Test the user use case: converting float result from modulo to int64."""
ds = ray.data.range(10)
# The modulo operation returns float, cast it to int64
ds = ds.with_column("part", (col("id") % 2).cast(DataType.int64()))
actual = ds.take_all()
expected_rows = [{"id": i, "part": i % 2} for i in range(10)]
assert rows_same(pd.DataFrame(actual), pd.DataFrame(expected_rows))
# Verify the schema shows int64 type
schema = ds.schema()
assert "part" in schema.names
part_type = schema.types[schema.names.index("part")]
assert part_type == pa.int64()
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("20.0.0"),
reason="with_column requires PyArrow >= 20.0.0",
)
def test_cast_expression_chained(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Test that cast() can be chained with other expressions."""
ds = ray.data.range(5)
# Cast to float64 then multiply
ds = ds.with_column("result", col("id").cast(DataType.float64()) * 2.5)
actual = ds.take_all()
expected_rows = [{"id": i, "result": i * 2.5} for i in range(5)]
assert rows_same(pd.DataFrame(actual), pd.DataFrame(expected_rows))
# Cast result of arithmetic operation
ds = ray.data.range(5)
ds = ds.with_column("result", (col("id") + 1).cast(DataType.string()))
actual = ds.take_all()
expected_rows = [{"id": i, "result": str(i + 1)} for i in range(5)]
assert rows_same(pd.DataFrame(actual), pd.DataFrame(expected_rows))
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("20.0.0"),
reason="with_column requires PyArrow >= 20.0.0",
)
def test_cast_expression_safe_mode(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Test that safe=True (default) raises errors on invalid conversions."""
ds = ray.data.from_items([{"value": "not_a_number"}])
# Attempting to cast non-numeric string to int should raise an error
with pytest.raises((UserCodeException, ValueError, pa.ArrowInvalid)):
ds.with_column("result", col("value").cast(DataType.int64())).materialize()
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("20.0.0"),
reason="with_column requires PyArrow >= 20.0.0",
)
def test_cast_expression_invalid_type(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Test that invalid type targets raise appropriate errors."""
ds = ray.data.range(5)
# Passing a non-DataType target should raise TypeError
with pytest.raises(
TypeError, match="target_type must be a ray.data.datatype.DataType"
):
ds.with_column("result", col("id").cast("invalid_type")).materialize()
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("20.0.0"),
reason="with_column requires PyArrow >= 20.0.0",
)
def test_cast_expression_multiple_types(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Test casting with multiple different target types."""
ds = ray.data.from_items([{"id": 42, "score": 3.14}])
# Cast id to different types
ds = ds.with_column("id_int", col("id").cast(DataType.int64()))
ds = ds.with_column("id_float", col("id").cast(DataType.float64()))
ds = ds.with_column("id_str", col("id").cast(DataType.string()))
# Cast score to int (use safe=False to allow float truncation to int)
ds = ds.with_column("score_int", col("score").cast(DataType.int64(), safe=False))
# Use rows_same to compare the full row content (expects DataFrames).
results = ds.take_all()
expected = [
{
"id": 42,
"score": 3.14,
"id_int": 42,
"id_float": 42.0,
"id_str": "42",
"score_int": 3,
}
]
assert rows_same(pd.DataFrame(results), pd.DataFrame(expected))
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("20.0.0"),
reason="with_column requires PyArrow >= 20.0.0",
)
def test_cast_expression_python_type_datatype_error(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Test that using Python-type-backed DataType in cast() raises a clear error."""
# Error is raised at expression build time when cast() is called (not at materialize).
error_match = "Python-type-backed DataType.*requires.*values"
with pytest.raises(TypeError, match=error_match):
col("id").cast(DataType(int))
with pytest.raises(TypeError, match=error_match):
col("id").cast(DataType(str))
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", __file__]))