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

431 lines
16 KiB
Python

import io
import pandas as pd
import pyarrow as pa
import pytest
from PIL import Image
import ray
from ray.data.expressions import DownloadExpr, col, download
class TestDownloadExpressionStructure:
"""Test DownloadExpr structural equality and basic properties."""
def test_download_expression_creation(self):
"""Test that download() creates a DownloadExpr with correct properties."""
expr = download("uri_column")
assert isinstance(expr, DownloadExpr)
assert expr.uri_column_name == "uri_column"
def test_download_expression_structural_equality(self):
"""Test structural equality comparison for download expressions."""
# Same expressions should be equal
expr1 = download("uri")
expr2 = download("uri")
assert expr1.structurally_equals(expr2)
assert expr2.structurally_equals(expr1)
# Different URI column names should not be equal
expr3 = download("different_uri")
assert not expr1.structurally_equals(expr3)
assert not expr3.structurally_equals(expr1)
# Compare with non-DownloadExpr
non_download_expr = col("uri")
assert not expr1.structurally_equals(non_download_expr)
assert not non_download_expr.structurally_equals(expr1)
class TestDownloadExpressionFunctionality:
"""Test actual download functionality with real and mocked data."""
def test_download_expression_with_local_files(self, tmp_path):
"""Test basic download expression functionality with local files."""
# Create sample files with different content types
sample_data = [
b"This is test file 1 content",
b"Different content for file 2",
b"File 3 has some binary data: \x00\x01\x02\x03",
]
file_paths = []
for i, data in enumerate(sample_data):
file_path = tmp_path / f"test_file_{i}.txt"
file_path.write_bytes(data)
file_paths.append(str(file_path))
# Create dataset with file URIs and metadata
table = pa.Table.from_arrays(
[
pa.array([f"local://{path}" for path in file_paths]),
pa.array([f"id_{i}" for i in range(len(file_paths))]),
pa.array([f"metadata_{i}" for i in range(len(file_paths))]),
pa.array(range(len(file_paths))),
],
names=["file_uri", "file_id", "metadata", "index"],
)
ds = ray.data.from_arrow(table)
# Add download column using expression
ds_with_downloads = ds.with_column("file_bytes", download("file_uri"))
# Verify results
results = ds_with_downloads.take_all()
assert len(results) == len(sample_data)
for i, result in enumerate(results):
# Download column should be added correctly
assert "file_bytes" in result
assert result["file_bytes"] == sample_data[i]
# All original columns should be preserved
assert result["file_id"] == f"id_{i}"
assert result["metadata"] == f"metadata_{i}"
assert result["index"] == i
assert result["file_uri"] == f"local://{file_paths[i]}"
def test_download_expression_empty_dataset(self):
"""Test download expression with empty dataset."""
# Create empty dataset with correct schema
table = pa.Table.from_arrays(
[
pa.array([], type=pa.string()),
],
names=["uri"],
)
ds = ray.data.from_arrow(table)
ds_with_downloads = ds.with_column("bytes", download("uri"))
results = ds_with_downloads.take_all()
assert len(results) == 0
def test_download_expression_with_different_file_types(self, tmp_path):
"""Test download expression with various file types including actual images."""
# Create a small 8x8 RGB image
small_image = Image.new("RGB", (8, 8), color=(255, 0, 0)) # Red 8x8 image
image_buffer = io.BytesIO()
small_image.save(image_buffer, format="PNG")
image_bytes = image_buffer.getvalue()
# Create files with different types of content
test_files = [
("text_file.txt", b"Simple text content"),
("binary_file.dat", b"\x00\x01\x02\x03\x04\x05"),
("json_file.json", b'{"key": "value", "number": 123}'),
("small_image.png", image_bytes), # Actual PNG image (primary use case)
("empty_file.txt", b""), # Empty file edge case
]
file_paths = []
expected_data = []
for filename, content in test_files:
file_path = tmp_path / filename
file_path.write_bytes(content)
file_paths.append(str(file_path))
expected_data.append(content)
# Create dataset
table = pa.Table.from_arrays(
[
pa.array([f"local://{path}" for path in file_paths]),
pa.array(
[f.split(".")[0] for f, _ in test_files]
), # filename without extension
],
names=["file_uri", "file_type"],
)
ds = ray.data.from_arrow(table)
ds_with_downloads = ds.with_column("content", download("file_uri"))
results = ds_with_downloads.take_all()
assert len(results) == len(test_files)
for i, result in enumerate(results):
assert result["content"] == expected_data[i]
assert result["file_type"] == test_files[i][0].split(".")[0]
# Special verification for image file - ensure it can be loaded as an image
if test_files[i][0].endswith(".png"):
downloaded_image = Image.open(io.BytesIO(result["content"]))
assert downloaded_image.size == (8, 8)
assert downloaded_image.mode == "RGB"
def test_chained_download_expressions(self, tmp_path):
"""Test chained download expressions functionality."""
# Create sample files with different content
sample_data = [
b"Content for file 1",
b"Content for file 2",
b"Content for file 3",
]
file_paths = []
for i, data in enumerate(sample_data):
file_path = tmp_path / f"test_file_{i}.txt"
file_path.write_bytes(data)
file_paths.append(str(file_path))
# Create dataset with file URIs
table = pa.Table.from_arrays(
[
pa.array([f"local://{path}" for path in file_paths]),
pa.array([f"id_{i}" for i in range(len(file_paths))]),
],
names=["file_uri", "file_id"],
)
ds = ray.data.from_arrow(table)
# Chain multiple download expressions from the same URI column
ds_with_chained_downloads = (
ds.with_column("file_bytes_1", download("file_uri"))
.with_column("file_bytes_2", download("file_uri"))
.with_column("file_bytes_3", download("file_uri"))
)
# Verify results
results = ds_with_chained_downloads.take_all()
assert len(results) == len(sample_data)
for i, result in enumerate(results):
# All download columns should have the same content
assert "file_bytes_1" in result
assert "file_bytes_2" in result
assert "file_bytes_3" in result
assert result["file_bytes_1"] == sample_data[i]
assert result["file_bytes_2"] == sample_data[i]
assert result["file_bytes_3"] == sample_data[i]
# Original columns should be preserved
assert result["file_id"] == f"id_{i}"
assert result["file_uri"] == f"local://{file_paths[i]}"
def test_download_expression_with_pandas_blocks(self, tmp_path):
"""Test download with pandas blocks to ensure arrow conversion works.
This tests the code path in PartitionActor / AsyncPartitionActor.__call__ where non-arrow
blocks are converted to arrow format before processing.
"""
ctx = ray.data.context.DataContext.get_current()
old_enable_pandas_block = ctx.enable_pandas_block
ctx.enable_pandas_block = True
try:
# Create test files
sample_data = [
b"Pandas block test content 1",
b"Pandas block test content 2",
]
file_paths = []
for i, data in enumerate(sample_data):
file_path = tmp_path / f"pandas_test_{i}.txt"
file_path.write_bytes(data)
file_paths.append(str(file_path))
# Create dataset with pandas blocks (not arrow)
df = pd.DataFrame(
{
"file_uri": [f"local://{path}" for path in file_paths],
"file_id": [f"id_{i}" for i in range(len(file_paths))],
}
)
ds = ray.data.from_pandas(df)
# Apply download - this should trigger arrow conversion in the partition actor
ds_with_downloads = ds.with_column("content", download("file_uri"))
# Verify results
results = ds_with_downloads.take_all()
assert len(results) == len(sample_data)
for i, result in enumerate(results):
assert result["content"] == sample_data[i]
assert result["file_id"] == f"id_{i}"
assert result["file_uri"] == f"local://{file_paths[i]}"
finally:
ctx.enable_pandas_block = old_enable_pandas_block
def test_download_expression_with_custom_filesystem(self, tmp_path):
import pyarrow.fs as pafs
# 1. Setup paths
subdir = tmp_path / "data"
subdir.mkdir()
file_name = "test_file.txt"
file_path = subdir / file_name
sample_content = b"File content with custom fs"
file_path.write_bytes(sample_content)
# 2. Setup SubTreeFileSystem
# This treats 'subdir' as the root '/'
base_fs = pafs.LocalFileSystem()
custom_fs = pafs.SubTreeFileSystem(str(subdir), base_fs)
# 3. Create Dataset
# Note: We use the relative 'file_name' because the FS is rooted at 'subdir'
ds = ray.data.from_items([{"file_uri": file_name, "file_id": 0}])
# 4. Execute Download
ds_with_downloads = ds.with_column(
"content", download("file_uri", filesystem=custom_fs)
)
# 5. Assertions
results = ds_with_downloads.take_all()
assert len(results) == 1
assert results[0]["content"] == sample_content
assert results[0]["file_id"] == 0
class TestDownloadExpressionErrors:
"""Test error conditions and edge cases for download expressions."""
def test_download_expression_invalid_uri_column(self):
"""Test download expression with non-existent URI column."""
table = pa.Table.from_arrays(
[
pa.array(["local://test.txt"]),
],
names=["existing_column"],
)
ds = ray.data.from_arrow(table)
ds_with_downloads = ds.with_column("bytes", download("non_existent_column"))
# Should raise error when trying to execute
with pytest.raises(ValueError):
ds_with_downloads.take_all()
def test_download_expression_with_null_uris(self):
"""Test download expression handling of null/empty URIs."""
table = pa.Table.from_arrays(
[
pa.array(["local://test.txt", None, ""]),
],
names=["uri"],
)
ds = ray.data.from_arrow(table)
ds_with_downloads = ds.with_column("bytes", download("uri"))
# Should handle nulls gracefully (exact behavior may vary)
# This test mainly ensures no crash occurs
try:
results = ds_with_downloads.take_all()
# If it succeeds, verify structure is reasonable
assert len(results) == 3
for result in results:
assert "bytes" in result
except Exception as e:
# If it fails, should be a reasonable error (not a crash)
assert isinstance(e, (ValueError, KeyError, RuntimeError))
def test_download_expression_with_malformed_uris(self, tmp_path):
"""Test download expression with malformed URIs.
This tests that various malformed URIs are caught and return None
instead of crashing.
All of the URIs should be malformed in order to test the ZeroDivisionError
described in https://github.com/ray-project/ray/issues/58462.
"""
malformed_uris = [
f"local://{tmp_path}/nonexistent.txt", # File doesn't exist
"local:///this/path/does/not/exist/file.txt", # Invalid path
"", # Empty URI
"foobar", # Random string
# TODO(xyuzh): Currently, using the below URIs raises an exception
# in _resolve_paths_and_filesystem. We need to fix that issue and
# add the tests in.
# "file:///\x00/null/byte", # Null byte
# "http://host/path\n\r", # Line breaks
# "foo://bar", # Invalid scheme
# "://no-scheme", # Missing scheme
# "http://host/path?query=<script>", # Injection attempts
]
ds = ray.data.from_items([{"uri": uri} for uri in malformed_uris])
ds_with_downloads = ds.with_column("bytes", download("uri"))
results = ds_with_downloads.take_all()
# All malformed URIs should return None
assert len(results) == len(malformed_uris)
for result in results:
assert result["bytes"] is None
def test_download_expression_mixed_valid_and_invalid_uris(self, tmp_path):
"""Test download expression when some but not all of the URIs are invalid."""
# Create one valid file
valid_file = tmp_path / "valid.txt"
valid_file.write_bytes(b"valid content")
# Create URIs: one valid and one non-existent file.
ds = ray.data.from_items(
[
{"uri": str(valid_file), "id": 0},
{"uri": str(tmp_path / "nonexistent.txt"), "id": 1},
]
)
ds_with_downloads = ds.with_column("bytes", download("uri"))
# Should not crash - failed downloads return None
results = sorted(ds_with_downloads.take_all(), key=lambda row: row["id"])
assert len(results) == 2
# First URI should succeed
assert results[0]["bytes"] == b"valid content"
# Second URI should fail gracefully (return None)
assert results[1]["bytes"] is None
class TestDownloadExpressionIntegration:
"""Integration tests combining download expressions with other Ray Data operations."""
def test_download_expression_with_map_batches(self, tmpdir):
"""Test download expression followed by map_batches processing."""
# Create a test file
test_file = tmpdir.join("test.txt")
test_content = b"Hello, World!"
test_file.write_binary(test_content)
# Create dataset
table = pa.Table.from_arrays(
[
pa.array([f"local://{test_file}"]),
],
names=["uri"],
)
ds = ray.data.from_arrow(table)
# Download then process
ds_with_content = ds.with_column("raw_bytes", download("uri"))
def decode_bytes(batch):
# Access the specific column containing the bytes data
batch["decoded_text"] = [
data.decode("utf-8") for data in batch["raw_bytes"]
]
return batch
ds_decoded = ds_with_content.map_batches(decode_bytes)
results = ds_decoded.take_all()
assert len(results) == 1
assert results[0]["decoded_text"] == "Hello, World!"
assert results[0]["raw_bytes"] == test_content
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))