Files
2026-07-13 13:17:40 +08:00

509 lines
16 KiB
Python

import os
from typing import Any, Dict, Iterator, List
from urllib.parse import urlparse
import pyarrow
import pytest
from pytest_lazy_fixtures import lf as lazy_fixture
import ray
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasource import ReadTask
from ray.data.datasource.file_based_datasource import (
FileBasedDatasource,
)
from ray.data.datasource.partitioning import (
Partitioning,
PartitionStyle,
PathPartitionFilter,
)
class MockFileBasedDatasource(FileBasedDatasource):
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
builder = DelegatingBlockBuilder()
builder.add({"data": f.readall()})
yield builder.build()
def execute_read_tasks(tasks: List[ReadTask]) -> List[Dict[str, Any]]:
"""Execute the read tasks and return the resulting rows.
The motivation for this utility function is so that we can test datasources without
scheduling Ray tasks.
"""
builder = DelegatingBlockBuilder()
for task in tasks:
for block in task():
builder.add_block(block)
block = builder.build()
block_accessor = BlockAccessor.for_block(block)
rows = list(block_accessor.iter_rows(public_row_format=True))
return rows
def strip_scheme(uri):
"""Remove scheme from a URI, if it exists."""
parsed = urlparse(uri)
if parsed.scheme:
return uri.split("://", 1)[1] # remove scheme
return uri # no scheme, return as-is
@pytest.mark.parametrize(
"filesystem,dir_path,endpoint_url",
[
(None, lazy_fixture("local_path"), None),
(lazy_fixture("local_fs"), lazy_fixture("local_path"), None),
(lazy_fixture("s3_fs"), lazy_fixture("s3_path"), lazy_fixture("s3_server")),
(
lazy_fixture("s3_fs_with_space"),
lazy_fixture("s3_path_with_space"),
lazy_fixture("s3_server"),
),
(
lazy_fixture("s3_fs_with_special_chars"),
lazy_fixture("s3_path_with_special_chars"),
lazy_fixture("s3_server"),
),
],
)
def test_read_single_file(ray_start_regular_shared, filesystem, dir_path, endpoint_url):
# `FileBasedDatasource` should read from the local filesystem if you don't specify
# one.
write_filesystem = filesystem
if write_filesystem is None:
write_filesystem = pyarrow.fs.LocalFileSystem()
file_uri = os.path.join(dir_path, "file.txt")
# PyArrow filesystems expect paths without schemes. `FileBasedDatasource` handles
# this internally, but we need to manually strip the scheme for the test setup.
write_path = strip_scheme(file_uri)
with write_filesystem.open_output_stream(write_path) as f:
f.write(b"spam")
datasource = MockFileBasedDatasource(file_uri, filesystem=filesystem)
tasks = datasource.get_read_tasks(1)
rows = execute_read_tasks(tasks)
assert rows == [{"data": b"spam"}]
def test_read_single_directory(ray_start_regular_shared, tmp_path):
dir_path = tmp_path / "dir"
dir_path.mkdir()
p1 = dir_path / "a.txt"
p1.write_bytes(b"a")
p2 = dir_path / "b.txt"
p2.write_bytes(b"b")
datasource = MockFileBasedDatasource(dir_path)
rows = execute_read_tasks(datasource.get_read_tasks(1))
assert sorted(rows, key=lambda r: r["data"]) == [{"data": b"a"}, {"data": b"b"}]
def test_read_dir_and_file_mixed(ray_start_regular_shared, tmp_path):
dir_path = tmp_path / "dir"
dir_path.mkdir()
p1 = dir_path / "a.txt"
p1.write_bytes(b"a")
p2 = tmp_path / "c.txt"
p2.write_bytes(b"c")
datasource = MockFileBasedDatasource([str(dir_path), str(p2)])
rows = execute_read_tasks(datasource.get_read_tasks(1))
assert sorted(rows, key=lambda r: r["data"]) == [{"data": b"a"}, {"data": b"c"}]
def test_pathlib_paths(ray_start_regular_shared, tmp_path):
"""Test that FileBasedDatasource accepts pathlib.Path objects."""
from pathlib import Path
path = Path(tmp_path) / "test_pathlib"
path.mkdir()
# Create pathlib.Path objects
file1 = path / "file1.txt"
file2 = path / "file2.txt"
file1.write_bytes(b"hello")
file2.write_bytes(b"world")
# Verify list of pathlib.Path works
datasource = MockFileBasedDatasource([file1, file2])
rows = execute_read_tasks(datasource.get_read_tasks(1))
assert sorted(rows, key=lambda r: r["data"]) == [
{"data": b"hello"},
{"data": b"world"},
]
# Verify single pathlib.Path works
datasource = MockFileBasedDatasource(file1)
rows = execute_read_tasks(datasource.get_read_tasks(1))
assert rows == [{"data": b"hello"}]
def test_single_file_infinite_target_max_block_size(
ray_start_regular_shared, target_max_block_size_infinite_or_default, tmp_path
):
path = tmp_path / "file.txt"
path.write_bytes(b"spam")
datasource = MockFileBasedDatasource(path)
rows = execute_read_tasks(datasource.get_read_tasks(1))
assert rows == [{"data": b"spam"}]
def test_partitioning_hive(ray_start_regular_shared, tmp_path):
path = os.path.join(tmp_path, "country=us")
os.mkdir(path)
with open(os.path.join(path, "file.txt"), "wb") as file:
file.write(b"")
datasource = MockFileBasedDatasource(tmp_path, partitioning=Partitioning("hive"))
tasks = datasource.get_read_tasks(1)
rows = execute_read_tasks(tasks)
assert rows == [{"data": b"", "country": "us"}]
def test_partition_filter_hive(ray_start_regular_shared, tmp_path):
for country in ["us", "jp"]:
path = os.path.join(tmp_path, f"country={country}")
os.mkdir(path)
with open(os.path.join(path, "file.txt"), "wb") as file:
file.write(b"")
filter = PathPartitionFilter.of(
style=PartitionStyle.HIVE,
filter_fn=lambda partitions: partitions["country"] == "us",
)
datasource = MockFileBasedDatasource(
tmp_path, partitioning=Partitioning("hive"), partition_filter=filter
)
tasks = datasource.get_read_tasks(1)
rows = execute_read_tasks(tasks)
assert rows == [{"data": b"", "country": "us"}]
def test_partitioning_dir(ray_start_regular_shared, tmp_path):
path = os.path.join(tmp_path, "us")
os.mkdir(path)
with open(os.path.join(path, "file.txt"), "wb") as file:
file.write(b"")
datasource = MockFileBasedDatasource(
tmp_path,
partitioning=Partitioning("dir", field_names=["country"], base_dir=tmp_path),
)
tasks = datasource.get_read_tasks(1)
rows = execute_read_tasks(tasks)
assert rows == [{"data": b"", "country": "us"}]
def test_partition_filter_dir(ray_start_regular_shared, tmp_path):
for country in ["us", "jp"]:
path = os.path.join(tmp_path, country)
os.mkdir(path)
with open(os.path.join(path, "file.txt"), "wb") as file:
file.write(b"")
filter = PathPartitionFilter.of(
style=PartitionStyle.DIRECTORY,
base_dir=tmp_path,
field_names=["country"],
filter_fn=lambda partitions: partitions["country"] == "us",
)
partitioning = Partitioning("dir", field_names=["country"], base_dir=tmp_path)
datasource = MockFileBasedDatasource(
tmp_path, partitioning=partitioning, partition_filter=filter
)
tasks = datasource.get_read_tasks(1)
rows = execute_read_tasks(tasks)
assert rows == [{"data": b"", "country": "us"}]
def test_partitioning_raises_on_mismatch(ray_start_regular_shared, tmp_path):
"""Test when the partition key already exists in the data."""
class StubDatasource(FileBasedDatasource):
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
builder = DelegatingBlockBuilder()
builder.add({"country": f.readall()})
yield builder.build()
path = os.path.join(tmp_path, "country=us")
os.mkdir(path)
with open(os.path.join(path, "file.txt"), "wb") as file:
file.write(b"jp")
datasource = StubDatasource(tmp_path, partitioning=Partitioning("hive"))
# The data is `jp`, but the path contains `us`. Since the values are different,
# the datasource should raise a ValueError.
with pytest.raises(ValueError):
tasks = datasource.get_read_tasks(1)
execute_read_tasks(tasks)
def test_ignore_missing_paths_true(ray_start_regular_shared, tmp_path):
path = os.path.join(tmp_path, "file.txt")
with open(path, "wb") as file:
file.write(b"")
datasource = MockFileBasedDatasource(
[path, "missing.txt"], ignore_missing_paths=True
)
tasks = datasource.get_read_tasks(1)
rows = execute_read_tasks(tasks)
assert rows == [{"data": b""}]
def test_ignore_missing_paths_false(ray_start_regular_shared, tmp_path):
path = os.path.join(tmp_path, "file.txt")
with open(path, "wb") as file:
file.write(b"")
with pytest.raises(FileNotFoundError):
datasource = MockFileBasedDatasource(
[path, "missing.txt"], ignore_missing_paths=False
)
tasks = datasource.get_read_tasks(1)
execute_read_tasks(tasks)
def test_local_paths(ray_start_regular_shared, tmp_path):
path = os.path.join(tmp_path, "test.txt")
with open(path, "w"):
pass
datasource = MockFileBasedDatasource(path)
assert datasource.supports_distributed_reads
datasource = MockFileBasedDatasource(f"local://{path}")
assert not datasource.supports_distributed_reads
def test_local_paths_with_client_raises_error(ray_start_cluster_enabled, tmp_path):
ray_start_cluster_enabled.add_node(num_cpus=1)
ray_start_cluster_enabled.head_node._ray_params.ray_client_server_port = "10004"
ray_start_cluster_enabled.head_node.start_ray_client_server()
ray.init("ray://localhost:10004")
path = os.path.join(tmp_path, "test.txt")
with open(path, "w"):
pass
with pytest.raises(ValueError):
MockFileBasedDatasource(f"local://{path}")
def test_include_paths(ray_start_regular_shared, tmp_path):
path = os.path.join(tmp_path, "test.txt")
with open(path, "w"):
pass
datasource = MockFileBasedDatasource(path, include_paths=True)
ds = ray.data.read_datasource(datasource)
paths = [row["path"] for row in ds.take_all()]
assert paths == [path]
def test_file_extensions(ray_start_regular_shared, tmp_path):
csv_path = os.path.join(tmp_path, "file.csv")
with open(csv_path, "w") as file:
file.write("spam")
txt_path = os.path.join(tmp_path, "file.txt")
with open(txt_path, "w") as file:
file.write("ham")
datasource = MockFileBasedDatasource([csv_path, txt_path], file_extensions=None)
ds = ray.data.read_datasource(datasource)
assert sorted(ds.input_files()) == sorted([csv_path, txt_path])
datasource = MockFileBasedDatasource([csv_path, txt_path], file_extensions=["csv"])
ds = ray.data.read_datasource(datasource)
assert ds.input_files() == [csv_path]
def test_file_extensions_no_match_raises(ray_start_regular_shared, tmp_path):
txt_path = tmp_path / "file.txt"
txt_path.write_bytes(b"ham")
with pytest.raises(
ValueError,
match="No input files found to read with the following file extensions",
):
MockFileBasedDatasource([str(txt_path)], file_extensions=["csv"])
def test_flaky_read_task_retries(ray_start_regular_shared, tmp_path):
"""Test that flaky read tasks are retried for both the
default set of retried errors and a custom set of retried errors."""
csv_path = os.path.join(tmp_path, "file.csv")
with open(csv_path, "w") as file:
file.write("spam")
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
return self.value
default_retried_error = ray.data.context.DEFAULT_RETRIED_IO_ERRORS[0]
custom_retried_error = "AWS Error ACCESS_DENIED"
class FlakyFileBasedDatasource(MockFileBasedDatasource):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
CounterActor = ray.remote(Counter)
# This actor ref is shared across all read tasks.
self.counter = CounterActor.remote()
def _read_stream(self, f: "pyarrow.NativeFile", path: str):
count = ray.get(self.counter.increment.remote())
if count == 1:
raise RuntimeError(default_retried_error)
elif count == 2:
raise RuntimeError(custom_retried_error)
else:
yield from super()._read_stream(f, path)
ray.data.DataContext.get_current().retried_io_errors.append(custom_retried_error)
datasource = FlakyFileBasedDatasource([csv_path])
ds = ray.data.read_datasource(datasource)
assert len(ds.take()) == 1
@pytest.mark.parametrize(
"fs",
[pyarrow.fs.S3FileSystem(), pyarrow.fs.LocalFileSystem()],
)
@pytest.mark.parametrize(
"wrap_with_retries",
[True, False],
)
def test_s3_filesystem_serialization(fs, wrap_with_retries):
"""Tests that the S3FileSystem can be serialized and deserialized with
the serialization workaround (_S3FileSystemWrapper).
Also checks that filesystems wrapped with RetryingPyFileSystem are
properly unwrapped.
"""
import ray.cloudpickle as ray_pickle
from ray.data._internal.util import RetryingPyFileSystem
from ray.data.datasource.file_based_datasource import (
_unwrap_s3_serialization_workaround,
_wrap_s3_serialization_workaround,
)
orig_fs = fs
if wrap_with_retries:
fs = RetryingPyFileSystem.wrap(fs, retryable_errors=["DUMMY ERROR"])
wrapped_fs = _wrap_s3_serialization_workaround(fs)
unpickled_fs = ray_pickle.loads(ray_pickle.dumps(wrapped_fs))
unwrapped_fs = _unwrap_s3_serialization_workaround(unpickled_fs)
if wrap_with_retries:
assert isinstance(unwrapped_fs, RetryingPyFileSystem)
assert isinstance(unwrapped_fs.unwrap(), orig_fs.__class__)
assert unwrapped_fs.retryable_errors == ["DUMMY ERROR"]
else:
assert isinstance(unwrapped_fs, orig_fs.__class__)
@pytest.mark.parametrize("shuffle", [True, False, "file"])
def test_invalid_shuffle_arg_raises_error(ray_start_regular_shared, shuffle):
with pytest.raises(ValueError):
FileBasedDatasource("example://iris.csv", shuffle=shuffle)
@pytest.mark.parametrize("shuffle", [None, "files"])
def test_valid_shuffle_arg_does_not_raise_error(ray_start_regular_shared, shuffle):
FileBasedDatasource("example://iris.csv", shuffle=shuffle)
def test_shuffle_files_changes_order(ray_start_regular_shared, tmp_path):
NUM_FILES = 10
NUM_RUNS = 5
for i in range(NUM_FILES):
(tmp_path / f"file_{i:02d}.txt").write_bytes(f"data_{i}".encode())
datasource = MockFileBasedDatasource(
str(tmp_path), shuffle="files", include_paths=True
)
output_paths_list = []
# Run NUM_RUNS times to verify shuffle produces different orderings
for _ in range(NUM_RUNS):
tasks = datasource.get_read_tasks(1)
rows = execute_read_tasks(tasks)
output_filenames = [os.path.basename(row["path"]) for row in rows]
output_paths_list.append(output_filenames)
expected_order = [f"file_{i:02d}.txt" for i in range(NUM_FILES)]
# Verify shuffle produces non-deterministic orderings across runs
unique_orderings = {tuple(paths) for paths in output_paths_list}
assert len(unique_orderings) >= 2
# Verify all files are present in each run
for output_paths in output_paths_list:
assert sorted(output_paths) == sorted(expected_order)
def test_read_s3_file_error(shutdown_only, s3_path):
from ray.data.datasource.file_meta_provider import _handle_read_os_error
dummy_path = s3_path + "_dummy"
error_message = "Please check that file exists and has properly configured access."
with pytest.raises(OSError, match=error_message):
ray.data.read_parquet(dummy_path)
with pytest.raises(OSError, match=error_message):
ray.data.read_binary_files(dummy_path)
with pytest.raises(OSError, match=error_message):
ray.data.read_csv(dummy_path)
with pytest.raises(OSError, match=error_message):
ray.data.read_json(dummy_path)
with pytest.raises(OSError, match=error_message):
error = OSError(
f"Error creating dataset. Could not read schema from {dummy_path}: AWS "
"Error [code 15]: No response body.. Is this a 'parquet' file?"
)
_handle_read_os_error(error, dummy_path)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))