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__]))