|
| 1 | +"""Tests for vcztools.zarr_direct.BlockReader. |
| 2 | +
|
| 3 | +The contract: ``BlockReader.read_chunk(coords)`` returns the same |
| 4 | +ndarray as ``zarr.Array.blocks[coords]`` for every chunk position, |
| 5 | +across both Zarr v2 and v3 metadata, every codec we encounter in |
| 6 | +real fixtures, missing chunks (fill values), and boundary chunks. |
| 7 | +Sharded arrays are refused at construction. |
| 8 | +""" |
| 9 | + |
| 10 | +import itertools |
| 11 | +import warnings |
| 12 | + |
| 13 | +import anyio |
| 14 | +import numpy as np |
| 15 | +import numpy.testing as nt |
| 16 | +import pytest |
| 17 | +import zarr |
| 18 | +import zarr.codecs |
| 19 | +import zarr.storage |
| 20 | + |
| 21 | +from vcztools.zarr_direct import BlockReader |
| 22 | + |
| 23 | + |
| 24 | +def _all_chunk_coords(arr: zarr.Array): |
| 25 | + """Iterate every chunk index tuple for ``arr``.""" |
| 26 | + return itertools.product(*[range(n) for n in arr.cdata_shape]) |
| 27 | + |
| 28 | + |
| 29 | +def _assert_array_parity(arr: zarr.Array): |
| 30 | + """Every chunk read via BlockReader equals arr.blocks[coords].""" |
| 31 | + reader = BlockReader(arr) |
| 32 | + for coords in _all_chunk_coords(arr): |
| 33 | + expected = arr.blocks[coords] |
| 34 | + got = anyio.run(reader.read_chunk, coords) |
| 35 | + nt.assert_array_equal(got, expected, err_msg=f"mismatch at {coords}") |
| 36 | + assert got.shape == expected.shape, f"shape mismatch at {coords}" |
| 37 | + assert got.dtype == expected.dtype, f"dtype mismatch at {coords}" |
| 38 | + |
| 39 | + |
| 40 | +@pytest.fixture |
| 41 | +def synthetic_v3_group(): |
| 42 | + """Tiny v3 group with one int array, default codecs (Bytes + Zstd).""" |
| 43 | + store = zarr.storage.MemoryStore() |
| 44 | + g = zarr.group(store=store, zarr_format=3) |
| 45 | + arr = g.create_array(name="ints", shape=(7, 4), chunks=(3, 2), dtype=np.int32) |
| 46 | + arr[:] = np.arange(28, dtype=np.int32).reshape(7, 4) |
| 47 | + return g |
| 48 | + |
| 49 | + |
| 50 | +@pytest.fixture |
| 51 | +def synthetic_v2_group(): |
| 52 | + """v2 group with default Blosc compressor.""" |
| 53 | + store = zarr.storage.MemoryStore() |
| 54 | + g = zarr.group(store=store, zarr_format=2) |
| 55 | + arr = g.create_array(name="ints", shape=(7, 4), chunks=(3, 2), dtype=np.int32) |
| 56 | + arr[:] = np.arange(28, dtype=np.int32).reshape(7, 4) |
| 57 | + return g |
| 58 | + |
| 59 | + |
| 60 | +class TestBlockReaderSynthetic: |
| 61 | + """Parity + boundary + fill-value tests on tiny in-memory arrays.""" |
| 62 | + |
| 63 | + def test_v3_default_codecs(self, synthetic_v3_group): |
| 64 | + _assert_array_parity(synthetic_v3_group["ints"]) |
| 65 | + |
| 66 | + def test_v2_default_codecs(self, synthetic_v2_group): |
| 67 | + _assert_array_parity(synthetic_v2_group["ints"]) |
| 68 | + |
| 69 | + def test_uncompressed_v3(self): |
| 70 | + store = zarr.storage.MemoryStore() |
| 71 | + g = zarr.group(store=store, zarr_format=3) |
| 72 | + arr = g.create_array( |
| 73 | + name="x", |
| 74 | + shape=(7,), |
| 75 | + chunks=(3,), |
| 76 | + dtype=np.int32, |
| 77 | + compressors=None, |
| 78 | + filters=None, |
| 79 | + ) |
| 80 | + arr[:] = np.arange(7, dtype=np.int32) |
| 81 | + _assert_array_parity(arr) |
| 82 | + |
| 83 | + def test_vlen_strings_v3(self): |
| 84 | + store = zarr.storage.MemoryStore() |
| 85 | + g = zarr.group(store=store, zarr_format=3) |
| 86 | + with warnings.catch_warnings(): |
| 87 | + warnings.simplefilter("ignore") |
| 88 | + data = np.array( |
| 89 | + [["hello", "world"], ["foo", ""], ["", "bar"]], dtype="<U16" |
| 90 | + ) |
| 91 | + arr = g.create_array( |
| 92 | + name="s", shape=data.shape, chunks=(2, 2), dtype=data.dtype |
| 93 | + ) |
| 94 | + arr[:] = data |
| 95 | + _assert_array_parity(arr) |
| 96 | + |
| 97 | + def test_floats(self): |
| 98 | + """Float dtype round-trips; NaN handling is exercised by the |
| 99 | + real-fixture suite (which uses ``equal_nan``-aware comparison).""" |
| 100 | + store = zarr.storage.MemoryStore() |
| 101 | + g = zarr.group(store=store, zarr_format=3) |
| 102 | + arr = g.create_array(name="f", shape=(5,), chunks=(2,), dtype=np.float32) |
| 103 | + arr[:] = np.array([1.0, 2.5, -3.0, 0.0, 7.7], dtype=np.float32) |
| 104 | + _assert_array_parity(arr) |
| 105 | + |
| 106 | + def test_boundary_chunk_trim(self): |
| 107 | + """Boundary chunks return the actual (trimmed) shape, not the |
| 108 | + nominal padded shape that's stored on disk.""" |
| 109 | + store = zarr.storage.MemoryStore() |
| 110 | + g = zarr.group(store=store, zarr_format=3) |
| 111 | + arr = g.create_array(name="x", shape=(7,), chunks=(3,), dtype=np.int32) |
| 112 | + arr[:] = np.arange(7, dtype=np.int32) |
| 113 | + reader = BlockReader(arr) |
| 114 | + last = anyio.run(reader.read_chunk, (2,)) |
| 115 | + assert last.shape == (1,) |
| 116 | + nt.assert_array_equal(last, [6]) |
| 117 | + |
| 118 | + def test_missing_chunk_returns_fill_value(self): |
| 119 | + """A chunk key absent from the store decodes to fill-value-filled |
| 120 | + ndarray at the boundary-clamped shape.""" |
| 121 | + store = zarr.storage.MemoryStore() |
| 122 | + g = zarr.group(store=store, zarr_format=3) |
| 123 | + arr = g.create_array( |
| 124 | + name="x", shape=(6,), chunks=(3,), dtype=np.int32, fill_value=99 |
| 125 | + ) |
| 126 | + # Don't write any data — every chunk key is absent. |
| 127 | + reader = BlockReader(arr) |
| 128 | + for coords in [(0,), (1,)]: |
| 129 | + got = anyio.run(reader.read_chunk, coords) |
| 130 | + expected = arr.blocks[coords] |
| 131 | + nt.assert_array_equal(got, expected) |
| 132 | + nt.assert_array_equal(got, [99, 99, 99]) |
| 133 | + |
| 134 | + def test_chunk_key_at_root_path(self): |
| 135 | + """An array created at the group root has empty ``array.path``; |
| 136 | + the chunk key has no ``"<path>/"`` prefix.""" |
| 137 | + store = zarr.storage.MemoryStore() |
| 138 | + # Create a v3 array directly under the store root (no group). |
| 139 | + arr = zarr.create_array( |
| 140 | + store=store, shape=(5,), chunks=(2,), dtype=np.int32, zarr_format=3 |
| 141 | + ) |
| 142 | + arr[:] = np.arange(5, dtype=np.int32) |
| 143 | + reader = BlockReader(arr) |
| 144 | + assert reader.chunk_key((0,)) == "c/0" |
| 145 | + nt.assert_array_equal(anyio.run(reader.read_chunk, (0,)), [0, 1]) |
| 146 | + |
| 147 | + def test_decode_limiter_is_honoured(self): |
| 148 | + """A CapacityLimiter with capacity 1 must let read_chunk complete; |
| 149 | + we don't assert serialisation here, just that the path doesn't |
| 150 | + deadlock or error when a limiter is supplied.""" |
| 151 | + store = zarr.storage.MemoryStore() |
| 152 | + g = zarr.group(store=store, zarr_format=3) |
| 153 | + arr = g.create_array(name="x", shape=(5,), chunks=(2,), dtype=np.int32) |
| 154 | + arr[:] = np.arange(5, dtype=np.int32) |
| 155 | + reader = BlockReader(arr) |
| 156 | + |
| 157 | + async def run(): |
| 158 | + limiter = anyio.CapacityLimiter(1) |
| 159 | + return await reader.read_chunk((1,), decode_limiter=limiter) |
| 160 | + |
| 161 | + got = anyio.run(run) |
| 162 | + nt.assert_array_equal(got, [2, 3]) |
| 163 | + |
| 164 | + |
| 165 | +class TestBlockReaderShardingRefusal: |
| 166 | + def test_sharded_array_refused(self): |
| 167 | + store = zarr.storage.MemoryStore() |
| 168 | + g = zarr.group(store=store, zarr_format=3) |
| 169 | + arr = g.create_array( |
| 170 | + name="x", |
| 171 | + shape=(8, 8), |
| 172 | + chunks=(8, 8), |
| 173 | + shards=(8, 8), |
| 174 | + dtype=np.int32, |
| 175 | + ) |
| 176 | + arr[:] = np.arange(64, dtype=np.int32).reshape(8, 8) |
| 177 | + with pytest.raises(NotImplementedError, match="ShardingCodec"): |
| 178 | + BlockReader(arr) |
| 179 | + |
| 180 | + |
| 181 | +class TestBlockReaderRealFixtures: |
| 182 | + """Parity against committed VCZ fixtures — exercises the codecs and |
| 183 | + metadata produced by bio2zarr in real workloads (vlen UTF-8, blosc, |
| 184 | + zstd, transpose, NaN-bearing floats, etc.).""" |
| 185 | + |
| 186 | + @pytest.fixture(autouse=True) |
| 187 | + def _silence_zarr_warnings(self): |
| 188 | + with warnings.catch_warnings(): |
| 189 | + warnings.simplefilter("ignore") |
| 190 | + yield |
| 191 | + |
| 192 | + def _check_full_group_parity(self, root: zarr.Group): |
| 193 | + """Read every chunk of every array in the group through both paths |
| 194 | + and assert they match. Uses ``equal_nan=True`` since real fixtures |
| 195 | + contain NaN-encoded missing values.""" |
| 196 | + for name in sorted(root.array_keys()): |
| 197 | + arr = root[name] |
| 198 | + reader = BlockReader(arr) |
| 199 | + for coords in _all_chunk_coords(arr): |
| 200 | + expected = arr.blocks[coords] |
| 201 | + got = anyio.run(reader.read_chunk, coords) |
| 202 | + assert got.shape == expected.shape, ( |
| 203 | + f"{name}{coords}: shape {got.shape} vs {expected.shape}" |
| 204 | + ) |
| 205 | + assert got.dtype == expected.dtype, ( |
| 206 | + f"{name}{coords}: dtype {got.dtype} vs {expected.dtype}" |
| 207 | + ) |
| 208 | + if np.issubdtype(expected.dtype, np.floating): |
| 209 | + nt.assert_array_equal( |
| 210 | + np.where(np.isnan(expected), 0, expected), |
| 211 | + np.where(np.isnan(got), 0, got), |
| 212 | + err_msg=f"{name}{coords}", |
| 213 | + ) |
| 214 | + nt.assert_array_equal( |
| 215 | + np.isnan(expected), |
| 216 | + np.isnan(got), |
| 217 | + err_msg=f"{name}{coords} NaN mask", |
| 218 | + ) |
| 219 | + else: |
| 220 | + nt.assert_array_equal(got, expected, err_msg=f"{name}{coords}") |
| 221 | + |
| 222 | + def test_sample_v2(self, fx_sample_vcz): |
| 223 | + self._check_full_group_parity(fx_sample_vcz.group) |
| 224 | + |
| 225 | + def test_sample_v3(self, fx_sample_vcz3): |
| 226 | + self._check_full_group_parity(fx_sample_vcz3.group) |
| 227 | + |
| 228 | + def test_field_type_combos(self, fx_field_type_combos_vcz): |
| 229 | + self._check_full_group_parity(fx_field_type_combos_vcz.group) |
0 commit comments