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

471 lines
16 KiB
Python

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