import gzip import json import os import pandas as pd import pyarrow as pa import pyarrow.fs as fs import pyarrow.json as pajson import pytest import ray from ray.data import Schema from ray.data._internal.datasource.json_datasource import PandasJSONDatasource from ray.data._internal.pandas_block import PandasBlockBuilder from ray.data._internal.util import rows_same from ray.data.block import BlockAccessor from ray.data.datasource.file_based_datasource import ( FILE_SIZE_FETCH_PARALLELIZATION_THRESHOLD, ) from ray.data.tests.conftest import * # noqa from ray.tests.conftest import * # noqa # Set the test timeout to 6 minutes pytestmark = pytest.mark.timeout(360) def test_json_read( ray_start_regular_shared, target_max_block_size_infinite_or_default, tmp_path ): df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path1 = os.path.join(tmp_path, "test1.json") df1.to_json(path1, orient="records", lines=True) ds = ray.data.read_json(path1) dsdf = ds.to_pandas() pd.testing.assert_frame_equal(df1.astype(dsdf.dtypes.to_dict()), dsdf) # Metadata ops. assert ds.count() == 3 assert ds.input_files() == [path1] assert ds.schema() == Schema(pa.schema([("one", pa.int64()), ("two", pa.string())])) def test_zipped_json_read( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default ): df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path1 = os.path.join(tmp_path, "test1.json.gz") df1.to_json(path1, compression="gzip", orient="records", lines=True) ds = ray.data.read_json(path1) dsdf = ds.to_pandas() pd.testing.assert_frame_equal(df1.astype(dsdf.dtypes.to_dict()), dsdf) # Metadata ops. assert ds.count() == 3 assert ds.input_files() == [path1] def test_read_json_fallback_from_pyarrow_failure( ray_start_regular_shared, local_path, target_max_block_size_infinite_or_default ): # Try to read this with read_json() to trigger fallback logic # to read bytes with json.load(). data = [{"one": [1]}, {"one": [1, 2]}] path1 = os.path.join(local_path, "test1.json") with open(path1, "w") as f: json.dump(data, f) # pyarrow.json cannot read JSONs containing arrays of different lengths. from pyarrow import ArrowInvalid with pytest.raises(ArrowInvalid): pajson.read_json(path1) # Ray Data successfully reads this in by # falling back to json.load() when pyarrow fails. ds = ray.data.read_json(path1) assert ds.take_all() == data def test_json_read_with_read_options( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default, ): # Arrow's JSON ReadOptions isn't serializable in pyarrow < 8.0.0, so this test # covers our custom ReadOptions serializer. # TODO(Clark): Remove this test and our custom serializer once we require # pyarrow >= 8.0.0. df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path1 = os.path.join(tmp_path, "test1.json") df1.to_json(path1, orient="records", lines=True) ds = ray.data.read_json( path1, read_options=pajson.ReadOptions(use_threads=False, block_size=2**30), ) dsdf = ds.to_pandas() pd.testing.assert_frame_equal(df1.astype(dsdf.dtypes.to_dict()), dsdf) # Test metadata ops. assert ds.count() == 3 assert ds.input_files() == [path1] assert ds.schema() == Schema(pa.schema([("one", pa.int64()), ("two", pa.string())])) def test_json_read_with_parse_options( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default, ): # Arrow's JSON ParseOptions isn't serializable in pyarrow < 8.0.0, so this test # covers our custom ParseOptions serializer, similar to ReadOptions in above test. # TODO(chengsu): Remove this test and our custom serializer once we require # pyarrow >= 8.0.0. df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path1 = os.path.join(tmp_path, "test1.json") df1.to_json(path1, orient="records", lines=True) ds = ray.data.read_json( path1, parse_options=pajson.ParseOptions( explicit_schema=pa.schema([("two", pa.string())]), unexpected_field_behavior="ignore", ), ) dsdf = ds.to_pandas() assert len(dsdf.columns) == 1 pd.testing.assert_series_equal(df1["two"].astype(dsdf["two"].dtype), dsdf["two"]) # Test metadata ops. assert ds.count() == 3 assert ds.input_files() == [path1] assert ds.schema() == Schema(pa.schema([("two", pa.string())])) @pytest.mark.parametrize("override_num_blocks", [None, 1, 3]) def test_jsonl_lists( ray_start_regular_shared, tmp_path, override_num_blocks, target_max_block_size_infinite_or_default, ): """Test JSONL with mixed types and schemas.""" data = [ ["ray", "rocks", "hello"], ["oh", "no"], ["rocking", "with", "ray"], ] path = os.path.join(tmp_path, "test.jsonl") with open(path, "w") as f: for record in data: json.dump(record, f) f.write("\n") ds = ray.data.read_json(path, lines=True, override_num_blocks=override_num_blocks) result = ds.take_all() assert result[0] == {"0": "ray", "1": "rocks", "2": "hello"} assert result[1] == {"0": "oh", "1": "no", "2": None} assert result[2] == {"0": "rocking", "1": "with", "2": "ray"} def test_jsonl_mixed_types( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default ): """Test JSONL with mixed types and schemas.""" data = [ {"a": 1, "b": {"c": 2}}, # Nested dict {"a": 1, "b": {"c": 3}}, # Nested dict {"a": 1, "b": {"c": {"hello": "world"}}}, # Mixed Schema ] path = os.path.join(tmp_path, "test.jsonl") with open(path, "w") as f: for record in data: json.dump(record, f) f.write("\n") ds = ray.data.read_json(path, lines=True) result = ds.take_all() assert result[0] == data[0] # Dict stays as is assert result[1] == data[1] assert result[2] == data[2] def test_json_write( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default ): input_df = pd.DataFrame({"id": [0]}) ds = ray.data.from_blocks([input_df]) ds.write_json(tmp_path) output_df = pd.concat( [ pd.read_json(os.path.join(tmp_path, filename), lines=True) for filename in os.listdir(tmp_path) ] ) assert rows_same(input_df, output_df) @pytest.mark.parametrize("override_num_blocks", [None, 2]) def test_json_roundtrip( ray_start_regular_shared, tmp_path, override_num_blocks, target_max_block_size_infinite_or_default, ): df = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) ds = ray.data.from_pandas([df], override_num_blocks=override_num_blocks) ds.write_json(tmp_path) ds2 = ray.data.read_json(tmp_path) ds2df = ds2.to_pandas() assert rows_same(ds2df, df) for entry in ds2._execute().blocks: assert ( # pyrefly: ignore[no-matching-overload] BlockAccessor.for_block(ray.get(entry.ref)).size_bytes() == entry.metadata.size_bytes ) def test_json_read_small_file_unit_block_size( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default, ): """Test reading a small JSON file with unit block_size.""" df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path1 = os.path.join(tmp_path, "test1.json") df1.to_json(path1, orient="records", lines=True) ds = ray.data.read_json(path1, read_options=pajson.ReadOptions(block_size=1)) dsdf = ds.to_pandas() pd.testing.assert_frame_equal(df1.astype(dsdf.dtypes.to_dict()), dsdf) # Test metadata ops. assert ds.count() == 3 assert ds.input_files() == [path1] assert ds.schema() == Schema(pa.schema([("one", pa.int64()), ("two", pa.string())])) def test_json_read_file_larger_than_block_size( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default, ): """Test reading a JSON file larger than the block size.""" block_size = 1024 num_chars = 2500 num_rows = 3 df2 = pd.DataFrame( { "one": ["a" * num_chars for _ in range(num_rows)], "two": ["b" * num_chars for _ in range(num_rows)], } ) path2 = os.path.join(tmp_path, "test2.json") df2.to_json(path2, orient="records", lines=True) ds = ray.data.read_json( path2, read_options=pajson.ReadOptions(block_size=block_size) ) dsdf = ds.to_pandas() pd.testing.assert_frame_equal(df2.astype(dsdf.dtypes.to_dict()), dsdf) # Test metadata ops. assert ds.count() == num_rows assert ds.input_files() == [path2] assert ds.schema() == Schema( pa.schema([("one", pa.string()), ("two", pa.string())]) ) def test_json_read_negative_block_size_fallback( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default ): """Test reading JSON with negative block_size triggers fallback to json.load().""" df3 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path3 = os.path.join(tmp_path, "test3.json") df3.to_json(path3, orient="records", lines=True) # Negative Buffer Size, fails with arrow but succeeds in fallback to json.load() ds = ray.data.read_json(path3, read_options=pajson.ReadOptions(block_size=-1)) dsdf = ds.to_pandas() pd.testing.assert_frame_equal(df3.astype(dsdf.dtypes.to_dict()), dsdf) def test_json_read_zero_block_size_failure( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default ): """Test reading JSON with zero block_size fails in both arrow and fallback.""" df3 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path3 = os.path.join(tmp_path, "test3.json") df3.to_json(path3, orient="records", lines=True) # Zero Buffer Size, fails with arrow and fails in fallback to json.load() with pytest.raises(json.decoder.JSONDecodeError, match="Extra data"): ds = ray.data.read_json(path3, read_options=pajson.ReadOptions(block_size=0)) dsdf = ds.to_pandas() assert dsdf.equals(df3) @pytest.mark.parametrize("min_rows_per_file", [5, 10, 50]) def test_write_min_rows_per_file( tmp_path, ray_start_regular_shared, min_rows_per_file, target_max_block_size_infinite_or_default, ): ray.data.range(100, override_num_blocks=20).write_json( tmp_path, min_rows_per_file=min_rows_per_file ) for filename in os.listdir(tmp_path): with open(os.path.join(tmp_path, filename), "r") as file: num_rows_written = len(file.read().splitlines()) assert num_rows_written == min_rows_per_file def test_mixed_gzipped_json_files( ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default ): # Create a non-empty gzipped JSON file non_empty_file_path = os.path.join(tmp_path, "non_empty.json.gz") data = [{"col1": "value1", "col2": "value2", "col3": "value3"}] with gzip.open(non_empty_file_path, "wt", encoding="utf-8") as f: for record in data: json.dump(record, f) f.write("\n") # Create an empty gzipped JSON file empty_file_path = os.path.join(tmp_path, "empty.json.gz") with gzip.open(empty_file_path, "wt", encoding="utf-8"): pass # Write nothing to create an empty file # Attempt to read both files with Ray ds = ray.data.read_json( [non_empty_file_path, empty_file_path], arrow_open_stream_args={"compression": "gzip"}, ) # The dataset should only contain data from the non-empty file assert ds.count() == 1 # Iterate through each row in the dataset and compare with the expected data for row in ds.iter_rows(): assert row == data[0], f"Row {row} does not match expected {data[0]}" # Verify the data content using take retrieved_data = ds.take(1)[0] assert ( retrieved_data == data[0] ), f"Retrieved data {retrieved_data} does not match expected {data[0]}." def test_json_with_http_path_parallelization( ray_start_regular_shared, httpserver, target_max_block_size_infinite_or_default ): num_files = FILE_SIZE_FETCH_PARALLELIZATION_THRESHOLD urls = [] for i in range(num_files): httpserver.expect_request(f"/file{i}.json").respond_with_json({"id": i}) urls.append(httpserver.url_for(f"/file{i}.json")) ds = ray.data.read_json(urls) actual_rows = ds.take_all() expected_rows = [{"id": i} for i in range(num_files)] assert sorted(actual_rows, key=lambda row: row["id"]) == sorted( expected_rows, key=lambda row: row["id"] ) class TestPandasJSONDatasource: @pytest.mark.parametrize( "data", [{"a": []}, {"a": [1]}, {"a": [1, 2, 3]}], ids=["empty", "single", "multiple"], ) @pytest.mark.parametrize( "compression,filename", [("gzip", "test.json.gz"), ("infer", "test.json")], # infer = default ) def test_read_stream( self, data, tmp_path, compression, filename, target_max_block_size_infinite_or_default, ): # Setup test file. df = pd.DataFrame(data) path = os.path.join(tmp_path, filename) df.to_json(path, orient="records", lines=True, compression=compression) # Setup datasource. local_filesystem = fs.LocalFileSystem() source = PandasJSONDatasource( path, target_output_size_bytes=1, filesystem=local_filesystem ) # Read stream. block_builder = PandasBlockBuilder() with source._open_input_source(local_filesystem, path) as f: for block in source._read_stream(f, path): block_builder.add_block(block) block = block_builder.build() # Verify. assert rows_same(block, df) def test_read_stream_with_target_output_size_bytes( self, tmp_path, target_max_block_size_infinite_or_default ): # Setup test file. It contains 16 lines, each line is 8 MiB. df = pd.DataFrame({"data": ["a" * 8 * 1024 * 1024] * 16}) path = os.path.join(tmp_path, "test.json") df.to_json(path, orient="records", lines=True) # Setup datasource. It should read 32 MiB (4 lines) per output. local_filesystem = fs.LocalFileSystem() source = PandasJSONDatasource( path, target_output_size_bytes=32 * 1024 * 1024, filesystem=local_filesystem, ) # Read stream. block_builder = PandasBlockBuilder() with source._open_input_source(local_filesystem, path) as f: for block in source._read_stream(f, path): assert len(block) == 4 block_builder.add_block(block) block = block_builder.build() # Verify. assert rows_same(block, df) def test_read_stream_with_advanced_file_pointer( self, tmp_path, target_max_block_size_infinite_or_default ): # Setup test file. df = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]}) path = os.path.join(tmp_path, "test.json") df.to_json(path, orient="records", lines=True) # Setup datasource. local_filesystem = fs.LocalFileSystem() source = PandasJSONDatasource( path, target_output_size_bytes=1, filesystem=local_filesystem ) # Simulate retrying a stream read on a file handle that was already consumed. block_builder = PandasBlockBuilder() with source._open_input_source(local_filesystem, path) as f: f.read(1) for block in source._read_stream(f, path): block_builder.add_block(block) block = block_builder.build() # Verify. assert rows_same(block, df) if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", __file__]))