import logging import os from pathlib import Path from typing import Any import fsspec import numpy as np import pandas as pd import pyarrow.fs import pytest import zarr from pytest_lazy_fixtures import lf as lazy_fixture import ray from ray.data._internal.datasource import zarrv2_datasource from ray.data.block import BlockAccessor from ray.data.tests.conftest import * # noqa: F401, F403 def _execute_read_tasks(tasks) -> pd.DataFrame: frames = [ BlockAccessor.for_block(block).to_pandas() for task in tasks for block in task() ] return pd.concat(frames, ignore_index=True) def _reconstruct_array(df: pd.DataFrame, array_name: str) -> np.ndarray: """Concatenate all chunks of one array from a long-form result frame.""" sub = df[df["array"] == array_name].sort_values( "chunk_index", key=lambda col: col.map(tuple) ) return np.concatenate(list(sub["chunk"]), axis=0) def _write_real_zarr_store( store_path: Path, arrays: dict, # {name: (data, chunks)} ) -> Path: """Write a real Zarr v2 store from numpy arrays and consolidate metadata.""" root = zarr.open_group(str(store_path), mode="w") for name, (data, chunks) in arrays.items(): root.create_dataset(name, data=data, chunks=chunks, dtype=data.dtype) zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path))) return store_path @pytest.fixture def zarrv2_group_store(tmp_path) -> Path: """Two arrays at the store root, both 2-D and 1-D, axis-0-aligned (shape[0]==5).""" return _write_real_zarr_store( tmp_path / "group.zarr", { "images": (np.arange(20, dtype=" Path: """Single-array store with the array sitting directly at the store root.""" store_path = tmp_path / "root.zarr" arr = zarr.open( str(store_path), mode="w", shape=(5, 4), chunks=(2, 4), dtype=" Path: """A store mixing different ranks, shape[0]s, dtypes, and native chunk sizes. Mirrors the UMI-style real-world layout where ``data/*`` arrays share an axis-0 timestep count but differ in everything else, and ``meta/*`` arrays live in a separate axis-0 universe entirely. The chunk-per-row datasource handles all of these in one read; nothing has to align. """ store_path = tmp_path / "heterogeneous.zarr" root = zarr.open_group(str(store_path), mode="w") # 4-D image tensor with tiny axis-0 chunks (1 image per chunk). root.create_dataset( "data/camera0_rgb", data=np.arange(20 * 2 * 2 * 3, dtype="|u1").reshape(20, 2, 2, 3), chunks=(1, 2, 2, 3), ) # 2-D pose array, same shape[0]=20, much larger axis-0 chunks (10). root.create_dataset( "data/robot0_eef_pos", data=np.arange(20 * 3, dtype=" Path: """Two arrays at the store root, no ``.zmetadata``. Exercises the no-``.zmetadata`` code paths (per-array ``.zarray`` discovery and full-store walk) — the common shape of real-world stores behind plain HTTPS or other listing-less filesystems. """ store_path = tmp_path / "unconsolidated.zarr" root = zarr.open_group(str(store_path), mode="w") root.create_dataset( "images", data=np.arange(20, dtype=" Path: """Three arrays sharing ``shape[0]=8``, different ranks and native chunks. Models the UMI-style case where data arrays co-stride on the timestep axis but differ in everything else. """ store_path = tmp_path / "aligned.zarr" root = zarr.open_group(str(store_path), mode="w") root.create_dataset( "img", data=np.arange(8 * 4 * 4 * 3, dtype="|u1").reshape(8, 4, 4, 3), chunks=(2, 4, 4, 3), ) root.create_dataset( "state", data=np.arange(8 * 3, dtype=" Path: """A small Zarr store packed into a ``.zip`` for URL-detection tests.""" src = tmp_path / "src.zarr" _write_real_zarr_store( src, { "data": (np.arange(12, dtype=" the per-array ``.zarray`` lookup path. with pytest.raises(ValueError, match="is a group, not an array"): zarrv2_datasource.ZarrV2Datasource(str(store_path), array_paths=["grp"]) def test_root_array_rejects_non_root_array_paths(zarrv2_root_store): """A single root-level array rejects array_paths that aren't the root ''.""" with pytest.raises(ValueError, match="single root-level array"): zarrv2_datasource.ZarrV2Datasource( str(zarrv2_root_store), array_paths=["missing"] ) # --------------------------------------------------------------------------- # chunk_shapes validation # --------------------------------------------------------------------------- @pytest.mark.parametrize( "chunk_shapes, match", [ ("invalid", "positive integers"), ({"images": 1}, "positive integers"), ({"does_not_exist": [2]}, "Unknown array path"), ], ) def test_rejects_invalid_chunk_shapes(zarrv2_group_store, chunk_shapes, match): with pytest.raises(ValueError, match=match): zarrv2_datasource.ZarrV2Datasource( str(zarrv2_group_store), chunk_shapes=chunk_shapes ) @pytest.mark.parametrize( "chunk_shapes,array_paths,expected", [ # No chunk_shapes: every array reads at its native chunk size. # 4-D image with tiny chunks coexists with 2-D pose with big chunks — # nothing is forced into a shared min/max. ( None, None, { "data/camera0_rgb": (1, 2, 2, 3), "data/robot0_eef_pos": (10, 3), "meta/episode_ends": (3,), }, ), # ``[5]`` prefix overrides axis 0 across arrays of all ranks at once. ( [5], None, { "data/camera0_rgb": (5, 2, 2, 3), "data/robot0_eef_pos": (5, 3), "meta/episode_ends": (5,), }, ), # Length-2 prefix overrides axes 0+1; needs every selected array to # have rank >= 2, so we filter out ``meta/episode_ends`` (rank 1). ( [5, 1], ["data/camera0_rgb", "data/robot0_eef_pos"], { "data/camera0_rgb": (5, 1, 2, 3), "data/robot0_eef_pos": (5, 1), }, ), # Per-array overrides may retile only some arrays while others keep # their native chunks. ( { "data/camera0_rgb": [5], "data/robot0_eef_pos": [7], }, None, { "data/camera0_rgb": (5, 2, 2, 3), "data/robot0_eef_pos": (7, 3), "meta/episode_ends": (3,), }, ), ], ) def test_chunk_shapes_resolution_across_mixed_rank( heterogeneous_zarrv2_store, chunk_shapes, array_paths, expected ): datasource = zarrv2_datasource.ZarrV2Datasource( str(heterogeneous_zarrv2_store), chunk_shapes=chunk_shapes, array_paths=array_paths, ) assert datasource._array_chunks == expected # --------------------------------------------------------------------------- # align_axis_0 (wide-form mode) # --------------------------------------------------------------------------- def test_align_axis_0_emits_wide_rows(ray_start_regular_shared, aligned_zarrv2_store): """Wide-row schema: ``t_start``, ``t_stop``, one column per selected array.""" datasource = zarrv2_datasource.ZarrV2Datasource( str(aligned_zarrv2_store), align_axis_0=True, chunk_shapes=[4], ) df = _execute_read_tasks(datasource.get_read_tasks(parallelism=4)) assert set(df.columns) == {"t_start", "t_stop", "img", "state", "label"} # shape[0]=8, chunk_shapes=[4] -> 2 rows. assert len(df) == 2 # Reconstruct each array by concatenating slices in order. img_recon = np.concatenate(list(df["img"]), axis=0) assert img_recon.shape == (8, 4, 4, 3) state_recon = np.concatenate(list(df["state"]), axis=0) assert state_recon.shape == (8, 3) label_recon = np.concatenate(list(df["label"]), axis=0) assert label_recon.shape == (8,) # t_start/t_stop are correct. starts = sorted(df["t_start"].tolist()) stops = sorted(df["t_stop"].tolist()) assert starts == [0, 4] assert stops == [4, 8] def test_align_axis_0_column_set(ray_start_regular_shared, aligned_zarrv2_store): """array_paths selects which arrays are read; aligned mode emits one column per selected array (plus t_start/t_stop).""" datasource = zarrv2_datasource.ZarrV2Datasource( str(aligned_zarrv2_store), array_paths=["img", "state"], align_axis_0=True, chunk_shapes=[4], ) df = _execute_read_tasks(datasource.get_read_tasks(parallelism=4)) assert set(df.columns) == {"t_start", "t_stop", "img", "state"} def test_align_axis_0_rejects_misaligned_shape0(heterogeneous_zarrv2_store): """Misalignment raises with the per-array shape[0] breakdown.""" with pytest.raises( ValueError, match=r"All selected arrays must share shape\[0\]", ): zarrv2_datasource.ZarrV2Datasource( str(heterogeneous_zarrv2_store), align_axis_0=True, chunk_shapes=[5], ) def test_align_axis_0_rejects_divergent_axis_0_chunks(aligned_zarrv2_store): """If aligned arrays end up with different axis-0 chunks, error clearly. The native chunks differ (img=2, state=4, label=8) — without a ``chunk_shapes`` re-tile they all stay at native, and the validator catches the mismatch. """ with pytest.raises( ValueError, match="Aligned arrays must share the same axis-0 chunk size" ): zarrv2_datasource.ZarrV2Datasource( str(aligned_zarrv2_store), align_axis_0=True, ) # --------------------------------------------------------------------------- # overlap (aligned-mode lookahead) # --------------------------------------------------------------------------- def test_overlap_extends_chunk_data(ray_start_regular_shared, aligned_zarrv2_store): """``overlap=N`` makes each row's per-array slice cover ``N`` extra timesteps. Aligned store has shape[0]=8, ``chunk_shapes=[4]`` -> rows own [0,4) and [4,8). With ``overlap=2``, row 0's data covers [0,6) and row 1's data covers [4,8) (clipped at the store end since 4+4+2 > 8). """ datasource = zarrv2_datasource.ZarrV2Datasource( str(aligned_zarrv2_store), align_axis_0=True, chunk_shapes=[4], overlap=2, ) df = _execute_read_tasks(datasource.get_read_tasks(parallelism=4)) # Ownership unchanged: 2 rows of width 4 each. assert sorted(zip(df["t_start"], df["t_stop"])) == [(0, 4), (4, 8)] # Data extents: row 0 has 6 timesteps, row 1 has 4 (clipped at shape[0]=8). rows = sorted(df.to_dict("records"), key=lambda r: r["t_start"]) assert rows[0]["img"].shape[0] == 6 # 4 owned + 2 overlap assert rows[0]["state"].shape[0] == 6 assert rows[1]["img"].shape[0] == 4 # 4 owned + 0 overlap (clipped) assert rows[1]["state"].shape[0] == 4 def test_overlap_requires_align_axis_0(aligned_zarrv2_store): """``overlap`` in long-form (no ``align_axis_0``) is a clear error.""" with pytest.raises(ValueError, match="overlap requires align_axis_0=True"): zarrv2_datasource.ZarrV2Datasource( str(aligned_zarrv2_store), overlap=2, ) def test_overlap_rejects_negative_and_non_int(aligned_zarrv2_store): bad_values: list[Any] = [-1, 1.5, "two"] for bad in bad_values: with pytest.raises(ValueError, match="overlap must be a non-negative integer"): zarrv2_datasource.ZarrV2Datasource( str(aligned_zarrv2_store), align_axis_0=True, chunk_shapes=[4], overlap=bad, ) def test_chunk_shapes_rejected_when_longer_than_smallest_array( heterogeneous_zarrv2_store, ): """A shared ``chunk_shapes`` override longer than a target rank is an error.""" with pytest.raises( ValueError, match=r"chunk_shapes override for array .* has 2 axes but array of shape .* has rank 1", ): zarrv2_datasource.ZarrV2Datasource( str(heterogeneous_zarrv2_store), chunk_shapes=[2, 2], # OK for 2-D and 4-D, fails for 1-D episode_ends ) # --------------------------------------------------------------------------- # Filesystem handling # --------------------------------------------------------------------------- def test_accepts_pyarrow_fs_filesystem(zarrv2_group_store): """A pyarrow.fs.FileSystem passed in is wrapped into fsspec internally.""" datasource = zarrv2_datasource.ZarrV2Datasource( str(zarrv2_group_store), filesystem=pyarrow.fs.LocalFileSystem(), ) from fsspec.spec import AbstractFileSystem assert isinstance(datasource._fs, AbstractFileSystem) assert set(datasource._metadata_by_path) == {"images", "nested"} def test_rejects_unsupported_filesystem_type(): """Filesystem that's neither pyarrow.fs nor fsspec raises ``TypeError``.""" with pytest.raises( TypeError, match=r"filesystem must be pyarrow\.fs\.FileSystem or", ): zarrv2_datasource.ZarrV2Datasource( "/tmp/some.zarr", filesystem="not-a-filesystem", ) # --------------------------------------------------------------------------- # .zarr.zip URL support # --------------------------------------------------------------------------- def test_reads_zarr_zip_local_path(ray_start_regular_shared, zarr_zip_store): """A local ``.zarr.zip`` path auto-wires fsspec's ZipFileSystem.""" datasource = zarrv2_datasource.ZarrV2Datasource(str(zarr_zip_store)) # The store has one array "data" of shape (6, 2) chunks (3, 2) -> 2 chunks. df = _execute_read_tasks(datasource.get_read_tasks(parallelism=2)) assert len(df) == 2 assert set(df["array"]) == {"data"} recon = _reconstruct_array(df, "data") np.testing.assert_array_equal(recon, np.arange(12, dtype=" grid (3, 2) = 6 chunks. _write_real_zarr_store( store_path, {"a": (np.arange(6 * 4, dtype=" two flat-index ranges; concatenated they must be in order. df = _execute_read_tasks(datasource.get_read_tasks(parallelism=2)) got = [tuple(int(x) for x in ci) for ci in df["chunk_index"]] assert got == list(product(range(3), range(2))) def test_per_task_row_limit_caps_chunks_read( ray_start_regular_shared, tmp_path, monkeypatch ): """per_task_row_limit bounds how many chunks a task actually reads, so a downstream ``limit`` doesn't pull the whole batch's I/O.""" store_path = tmp_path / "limit.zarr" _write_real_zarr_store(store_path, {"data": (np.arange(10, dtype=" one task batching all 10 chunks; cap it at 3. tasks = datasource.get_read_tasks(parallelism=1, per_task_row_limit=3) blocks = [block for task in tasks for block in task()] total_rows = sum(BlockAccessor.for_block(b).num_rows() for b in blocks) assert total_rows == 3 # The fix: only 3 chunks were actually read (not all 10, then truncated). assert len(reads) == 3 def test_read_chunk_retries_transient_io(monkeypatch): """_read_chunk retries reads whose error matches retry_match (Ray Data's DataContext.retried_io_errors), then succeeds.""" monkeypatch.setattr("time.sleep", lambda *_: None) # no backoff in the test class _FlakyArray: attempts = 0 def __getitem__(self, _idx): type(self).attempts += 1 if self.attempts < 3: raise OSError("Connection reset by peer") return np.arange(4, dtype="= 1 def test_align_axis_0_rejects_scalar_array(tmp_path): """align_axis_0=True with a 0-D (scalar) array must raise a clear error rather than an IndexError when reading the (empty) axis-0 chunk size.""" store_path = tmp_path / "scalar.zarr" root = zarr.open_group(str(store_path), mode="w") root.create_dataset("vec", data=np.arange(8, dtype=" 1 # actually exercise cross-block unification schemas = [BlockAccessor.for_block(b).to_arrow().schema for b in blocks] unified = unify_schemas(schemas) # must not raise assert {"t_start", "t_stop", "img", "state", "label"}.issubset(set(unified.names)) if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", __file__])) # --------------------------------------------------------------------------- # Custom codec registration in Ray workers # --------------------------------------------------------------------------- @pytest.fixture def fresh_ray(): """A clean Ray for a test that needs its own ``ray.init`` (e.g. a custom ``runtime_env``). Unlike ``shutdown_only`` (teardown only), it also shuts down any pre-existing cluster, so isolation doesn't depend on test order. """ if ray.is_initialized(): ray.shutdown() yield if ray.is_initialized(): ray.shutdown() def test_custom_codec_succeeds_with_worker_setup_hook(fresh_ray, tmp_path): """Test that we successfully register a custom codec. numcodecs' registry is process-local. """ import numcodecs def _register_codec(): import numcodecs import numpy as np class _RayZarrTestCodec(numcodecs.abc.Codec): codec_id = "ray_zarr_test_codec" def encode(self, buf): return bytes(buf) def decode(self, buf, out=None): arr = np.frombuffer(buf, dtype=np.uint8) if out is not None: out[:] = arr.view(out.dtype) return out return arr.copy() numcodecs.register_codec(_RayZarrTestCodec) # Register driver-side so we can write the store. _register_codec() store_path = tmp_path / "codec_test.zarr" arr = zarr.open( str(store_path), mode="w", shape=(8,), chunks=(4,), dtype="u1", compressor=numcodecs.get_codec({"id": "ray_zarr_test_codec"}), ) arr[:] = np.arange(8, dtype="u1") zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path))) ray.init( num_cpus=1, logging_level=logging.ERROR, log_to_driver=False, runtime_env={"worker_process_setup_hook": _register_codec}, ) ds = ray.data.read_zarr(str(store_path)) rows = sorted(ds.take_all(), key=lambda r: tuple(r["chunk_index"])) recon = np.concatenate([r["chunk"] for r in rows]) np.testing.assert_array_equal(recon, np.arange(8, dtype="u1"))