Skip to content

Commit 2b3051e

Browse files
committed
new zarr version handling
1 parent 5dfd582 commit 2b3051e

6 files changed

Lines changed: 170 additions & 69 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ env:
1616

1717
jobs:
1818
build_and_test:
19-
name: build and test
19+
name: build and test (zarr ${{ matrix.zarr_version }})
2020
strategy:
2121
fail-fast: false
2222
matrix:
2323
rust_toolchain: ["stable"] # "nightly"
24+
# "main" exercises features not yet in a zarr-python release (e.g. the
25+
# sharding `subchunk_write_order`); those tests skip on "released".
26+
zarr_version: ["released", "main"]
2427
runs-on: ubuntu-latest
2528
steps:
2629
- uses: actions/checkout@v5
@@ -53,6 +56,12 @@ jobs:
5356
run: |
5457
uv pip install --system -e . --group dev --verbose
5558
59+
- name: Install zarr-python from main
60+
if: matrix.zarr_version == 'main'
61+
run: |
62+
uv pip install --system --reinstall-package zarr \
63+
"zarr @ git+https://github.com/zarr-developers/zarr-python.git@main"
64+
5665
- name: Python Tests
5766
run: pytest -n auto
5867

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ The `ZarrsCodecPipeline` specific options are:
5252
- `codec_pipeline.strict`: raise exceptions for unsupported operations instead of falling back to the default codec pipeline of `zarr-python`.
5353
- Defaults to `False`.
5454

55-
The subchunk write order within a shard is derived from the `ShardingCodec`'s `subchunk_write_order` (`zarr-python` >= 3.2), not from a config option. `zarrs` writes `lexicographic` as row-major and falls back to unordered for `morton`/`colexicographic`/`unordered`, since it cannot reproduce those layouts (the data is identical, only the physical byte layout differs).
55+
The subchunk write order within a shard is derived from each `ShardingCodec`'s `subchunk_write_order` (`zarr-python` >= 3.2.2). Nested shards are handled per level: the order is read from the sharding codec at each nesting depth. `zarrs` falls back to `unordered` for `morton`/`colexicographic` but can handle `lexicographic` and `unordered` explicitly.
5656

5757
For example:
5858
```python

python/zarrs/_internal.pyi

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ class CodecPipelineImpl:
3030
chunk_concurrent_maximum: builtins.int | None = None,
3131
num_threads: builtins.int | None = None,
3232
direct_io: builtins.bool = False,
33-
subchunk_write_order: typing.Literal[
34-
"morton", "unordered", "lexicographic", "colexicographic"
35-
] = "unordered",
33+
subchunk_write_order: builtins.list[
34+
typing.Literal["morton", "unordered", "lexicographic", "colexicographic"]
35+
] = [],
3636
) -> CodecPipelineImpl: ...
3737
def retrieve_chunks_and_apply_index(
3838
self,

python/zarrs/pipeline.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import asyncio
44
import json
55
from dataclasses import dataclass
6-
from typing import TYPE_CHECKING, TypedDict
6+
from typing import TYPE_CHECKING, Literal, TypedDict
77
from warnings import warn
88

99
import numpy as np
1010
from zarr.abc.codec import Codec, CodecPipeline
11+
from zarr.codecs import ShardingCodec
1112
from zarr.codecs._v2 import V2Codec
1213
from zarr.core import BatchedCodecPipeline
1314
from zarr.core.config import config
@@ -32,6 +33,8 @@
3233
make_chunk_info_for_rust_with_indices,
3334
)
3435

36+
SubchunkWriteOrder = Literal["morton", "unordered", "lexicographic", "colexicographic"]
37+
3538

3639
class UnsupportedDataTypeError(Exception):
3740
pass
@@ -62,7 +65,7 @@ def get_codec_pipeline_impl(
6265
),
6366
num_threads=config.get("threading.max_workers", None),
6467
direct_io=config.get("codec_pipeline.direct_io", False),
65-
subchunk_write_order=_subchunk_write_order(metadata),
68+
subchunk_write_order=_subchunk_write_orders(metadata),
6669
)
6770
except TypeError as e:
6871
if strict:
@@ -90,16 +93,28 @@ class ZarrsCodecPipelineState(TypedDict):
9093
codecs: tuple[Codec, ...]
9194

9295

93-
def _subchunk_write_order(metadata: ArrayMetadata) -> str:
94-
# Derive the shard subchunk write order from the sharding codec itself
95-
# (zarr-python >=3.2). Older zarr always wrote morton order and has no
96-
# attribute; the default matches that. zarrs maps morton/colexicographic
97-
# to unordered since it can't reproduce those layouts.
98-
for codec in array_metadata_to_codecs(metadata):
99-
order = getattr(codec, "subchunk_write_order", None)
100-
if order is not None:
101-
return order
102-
return "morton"
96+
def _subchunk_write_orders(
97+
metadata: ArrayMetadata,
98+
) -> list[SubchunkWriteOrder]:
99+
"""Subchunk write order can be nested inside the sharding codec - return that order here.
100+
101+
Parameters
102+
----------
103+
metadata
104+
The array metadata containing the potentially nested shards.
105+
106+
Returns
107+
-------
108+
A list of subchunk write orders
109+
"""
110+
orders: list[SubchunkWriteOrder] = []
111+
codecs: Iterable[Codec] = array_metadata_to_codecs(metadata)
112+
while (
113+
sharding := next((c for c in codecs if isinstance(c, ShardingCodec)), None)
114+
) is not None:
115+
orders.append(getattr(sharding, "subchunk_write_order", "morton"))
116+
codecs = sharding.codecs
117+
return orders
103118

104119

105120
def array_metadata_to_codecs(metadata: ArrayMetadata) -> list[Codec]:

src/lib.rs

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ use rayon::iter::{IntoParallelIterator, ParallelIterator};
1818
use rayon_iter_concurrent_limit::iter_concurrent_limit;
1919
use unsafe_cell_slice::UnsafeCellSlice;
2020
use utils::is_whole_chunk;
21-
use zarrs::array::codec::{ShardingCodecOptions, SubchunkWriteOrder};
21+
use zarrs::array::codec::{ShardingCodec, ShardingCodecConfiguration, SubchunkWriteOrder};
2222
use zarrs::array::{
2323
ArrayBytes, ArrayBytesDecodeIntoTarget, ArrayBytesFixedDisjointView, ArrayMetadata,
24-
ArrayPartialDecoderTraits, ArrayToBytesCodecTraits, CodecChain, CodecOptions,
25-
CodecSpecificOptions, DataType, FillValue, StoragePartialDecoder, copy_fill_value_into,
26-
update_array_bytes,
24+
ArrayPartialDecoderTraits, ArrayToBytesCodecTraits, CodecChain, CodecOptions, DataType,
25+
FillValue, StoragePartialDecoder, copy_fill_value_into, update_array_bytes,
2726
};
2827
use zarrs::config::global_config;
2928
use zarrs::convert::array_metadata_v2_to_v3;
29+
use zarrs::metadata::v3::MetadataV3;
3030
use zarrs::plugin::ZarrVersion;
3131
use zarrs::storage::{ReadableWritableListableStorage, StorageHandle, StoreKey};
3232

@@ -42,6 +42,59 @@ use crate::concurrency::ChunkConcurrentLimitAndCodecOptions;
4242
use crate::store::StoreConfig;
4343
use crate::utils::{PyCodecErrExt, PyErrExt as _, SubchunkWriteOrderWrapper};
4444

45+
/// Build a codec chain from metadata, applying `orders[depth]` to the sharding
46+
/// codec at each nesting level (outermost = `orders[0]`). Sharding nests
47+
/// linearly (one array->bytes codec per chain), so the list is indexed by depth.
48+
/// Levels beyond the list, or an empty list, keep zarrs' default order.
49+
fn codec_chain_with_subchunk_write_orders(
50+
codecs: &[MetadataV3],
51+
orders: &[SubchunkWriteOrderWrapper],
52+
) -> PyResult<CodecChain> {
53+
let base = CodecChain::from_metadata(codecs).map_py_err::<PyTypeError>()?;
54+
let array_to_bytes = base.array_to_bytes_codec();
55+
let array_to_bytes: Arc<dyn ArrayToBytesCodecTraits> =
56+
if array_to_bytes.as_any().is::<ShardingCodec>() {
57+
let ShardingCodecConfiguration::V1(config) = codecs
58+
.iter()
59+
.find_map(|m| m.to_configuration::<ShardingCodecConfiguration>().ok())
60+
.ok_or_else(|| {
61+
PyErr::new::<PyTypeError, _>(
62+
"sharding codec present but its metadata was not found",
63+
)
64+
})?
65+
else {
66+
return Err(PyErr::new::<PyTypeError, _>(
67+
"unsupported sharding codec configuration version",
68+
));
69+
};
70+
let order = orders
71+
.first()
72+
.map_or(SubchunkWriteOrder::Unordered, |o| o.0);
73+
let inner = codec_chain_with_subchunk_write_orders(
74+
&config.codecs,
75+
orders.get(1..).unwrap_or_default(),
76+
)?;
77+
let index =
78+
CodecChain::from_metadata(&config.index_codecs).map_py_err::<PyTypeError>()?;
79+
Arc::new(
80+
ShardingCodec::new(
81+
config.chunk_shape,
82+
Arc::new(inner),
83+
Arc::new(index),
84+
config.index_location,
85+
)
86+
.with_subchunk_write_order(order),
87+
)
88+
} else {
89+
array_to_bytes.clone()
90+
};
91+
Ok(CodecChain::new(
92+
base.array_to_array_codecs().to_vec(),
93+
array_to_bytes,
94+
base.bytes_to_bytes_codecs().to_vec(),
95+
))
96+
}
97+
4598
// TODO: Use a OnceLock for store with get_or_try_init when stabilised?
4699
#[gen_stub_pyclass]
47100
#[pyclass]
@@ -212,6 +265,7 @@ impl CodecPipelineImpl {
212265
#[pymethods]
213266
impl CodecPipelineImpl {
214267
#[allow(clippy::too_many_arguments)] // python functions can have defaults
268+
#[allow(clippy::needless_pass_by_value)] // pyo3 extracts args by value
215269
#[pyo3(signature = (
216270
array_metadata,
217271
store_config,
@@ -221,7 +275,7 @@ impl CodecPipelineImpl {
221275
chunk_concurrent_maximum=None,
222276
num_threads=None,
223277
direct_io=false,
224-
subchunk_write_order=SubchunkWriteOrderWrapper(SubchunkWriteOrder::Unordered),
278+
subchunk_write_order=Vec::new(),
225279
))]
226280
#[new]
227281
fn new(
@@ -232,7 +286,10 @@ impl CodecPipelineImpl {
232286
chunk_concurrent_maximum: Option<usize>,
233287
num_threads: Option<usize>,
234288
direct_io: bool,
235-
subchunk_write_order: SubchunkWriteOrderWrapper,
289+
// One order per sharding-codec nesting level, outermost first. Sharding
290+
// nests linearly (a codec chain has exactly one array->bytes codec), so a
291+
// flat depth-indexed list is enough — no tree needed.
292+
subchunk_write_order: Vec<SubchunkWriteOrderWrapper>,
236293
) -> PyResult<Self> {
237294
store_config.direct_io(direct_io);
238295
let metadata = serde_json::from_str(array_metadata).map_py_err::<PyTypeError>()?;
@@ -242,16 +299,10 @@ impl CodecPipelineImpl {
242299
}
243300
ArrayMetadata::V3(v3) => Cow::Borrowed(v3),
244301
};
245-
let codec_chain = Arc::new(
246-
CodecChain::from_metadata(&metadata_v3.codecs)
247-
.map_py_err::<PyTypeError>()?
248-
.with_codec_specific_options(
249-
&CodecSpecificOptions::default().with_option(
250-
ShardingCodecOptions::default()
251-
.with_subchunk_write_order(subchunk_write_order.0),
252-
),
253-
),
254-
);
302+
let codec_chain = Arc::new(codec_chain_with_subchunk_write_orders(
303+
&metadata_v3.codecs,
304+
&subchunk_write_order,
305+
)?);
255306
let codec_options = CodecOptions::default().with_validate_checksums(validate_checksums);
256307

257308
let chunk_concurrent_minimum =

tests/test_sharding.py

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import inspect
22
import warnings
3-
from typing import Any, Literal
3+
from importlib.metadata import version
4+
from typing import Any, get_args
45

56
import numpy as np
67
import numpy.typing as npt
78
import pytest
8-
from zarr import AsyncArray, create_array
9+
from packaging.version import Version
10+
from zarr import config, create_array
911
from zarr.abc.store import Store
1012
from zarr.api.asynchronous import create_array as create_async_array
1113
from zarr.codecs import (
@@ -17,7 +19,9 @@
1719
from zarr.core.array import ShardsConfigParam
1820
from zarr.core.buffer import default_buffer_prototype
1921
from zarr.errors import ZarrUserWarning
20-
from zarr.storage import StorePath
22+
from zarr.storage import LocalStore, StorePath
23+
24+
from zarrs.pipeline import SubchunkWriteOrder
2125

2226
from .conftest import ArrayRequest
2327
from .test_codecs import _AsyncArrayProxy, order_from_dim
@@ -328,41 +332,63 @@ async def test_sharding_with_empty_inner_chunk(
328332
"subchunk_write_order" in inspect.signature(ShardingCodec).parameters
329333
)
330334

331-
332-
@pytest.mark.skipif(
333-
not _SHARDING_HAS_WRITE_ORDER,
335+
# `subchunk_write_order` on the sharding codec is a zarr-python >=3.2.2 feature.
336+
requires_write_order = pytest.mark.skipif(
337+
Version(version("zarr")) < Version("3.2.2dev0"),
334338
reason="zarr-python ShardingCodec has no subchunk_write_order",
335339
)
336-
@pytest.mark.parametrize(
337-
"index_location", [ShardingCodecIndexLocation.start, ShardingCodecIndexLocation.end]
338-
)
339-
# "lexicographic" -> C (sorted offsets); "morton" falls back to unordered in zarrs
340-
@pytest.mark.parametrize("subchunk_write_order", ["lexicographic", "morton"])
341-
async def test_sharding_subchunk_write_order(
342-
store: Store,
343-
index_location: ShardingCodecIndexLocation,
344-
subchunk_write_order: Literal["lexicographic", "morton"],
340+
341+
342+
@requires_write_order
343+
@pytest.mark.parametrize("nested", [False, True], ids=["flat", "nested"])
344+
@pytest.mark.parametrize("subchunk_write_order", list(get_args(SubchunkWriteOrder)))
345+
def test_subchunk_write_order_matches_zarr_python(
346+
tmp_path, *, subchunk_write_order: SubchunkWriteOrder, nested: bool
345347
) -> None:
346-
path = f"sharding_with_empty_inner_chunk_{index_location}"
347-
spath = StorePath(store, path)
348-
codec = ShardingCodec(
349-
chunk_shape=(2, 2),
350-
index_location=index_location,
351-
subchunk_write_order=subchunk_write_order,
352-
)
353-
a = await AsyncArray.create(
354-
spath,
355-
shape=(16, 16),
356-
chunk_shape=(16, 16),
357-
dtype="uint32",
358-
fill_value=0,
359-
codecs=[codec],
348+
data = np.arange(1, 32 * 32 + 1, dtype="uint32").reshape((32, 32))
349+
ground_truth_subchunk_write_order = (
350+
"unordered"
351+
if subchunk_write_order in {"colexicographic", "unordered", "morton"}
352+
else "lexicographic"
360353
)
361-
await a.setitem(..., np.arange(16 * 16).reshape((16, 16)))
362-
index = await codec._load_shard_index(a.store_path / "/c/0/0", (8, 8))
363-
index_offsets = index.offsets_and_lengths[index.get_full_chunk_map()].ravel()[::2]
364-
assert len(index_offsets) == 64 # 8 * 8
365-
if subchunk_write_order == "lexicographic":
366-
np.testing.assert_equal(np.sort(index_offsets), index_offsets)
354+
if nested:
355+
zarrs_codec = ShardingCodec(
356+
chunk_shape=(8, 8),
357+
subchunk_write_order=subchunk_write_order,
358+
codecs=[
359+
ShardingCodec(chunk_shape=(2, 2), subchunk_write_order="lexicographic")
360+
],
361+
)
362+
zarr_codec = ShardingCodec(
363+
chunk_shape=(8, 8),
364+
subchunk_write_order=ground_truth_subchunk_write_order,
365+
codecs=[
366+
ShardingCodec(chunk_shape=(2, 2), subchunk_write_order="lexicographic")
367+
],
368+
)
367369
else:
368-
assert not np.array_equal(np.sort(index_offsets), index_offsets)
370+
zarrs_codec = ShardingCodec(
371+
chunk_shape=(8, 8), subchunk_write_order="lexicographic"
372+
)
373+
zarr_codec = ShardingCodec(
374+
chunk_shape=(8, 8), subchunk_write_order=ground_truth_subchunk_write_order
375+
)
376+
377+
def write(pipeline: str) -> bytes:
378+
sub = tmp_path / pipeline.rsplit(".", 1)[-1]
379+
with config.set({"codec_pipeline.path": pipeline}):
380+
a = create_array(
381+
StorePath(LocalStore(sub)),
382+
shape=(32, 32),
383+
chunks=(32, 32),
384+
dtype="uint32",
385+
fill_value=0,
386+
serializer=zarrs_codec if "zarrs" in pipeline else zarr_codec,
387+
compressors=None,
388+
)
389+
a[:, :] = data
390+
return (sub / "c" / "0" / "0").read_bytes()
391+
392+
zarrs_bytes = write("zarrs.ZarrsCodecPipeline")
393+
zarr_bytes = write("zarr.core.codec_pipeline.BatchedCodecPipeline")
394+
assert zarrs_bytes == zarr_bytes

0 commit comments

Comments
 (0)