|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | | -from typing import Any |
| 5 | +from dataclasses import dataclass, field, replace |
| 6 | +from typing import TYPE_CHECKING, Any |
6 | 7 |
|
7 | 8 | import numpy as np |
8 | 9 | import pytest |
9 | 10 |
|
10 | 11 | import zarr |
11 | | -from zarr.abc.codec import BytesBytesCodec |
| 12 | +from zarr.abc.codec import ( |
| 13 | + ArrayBytesCodec, |
| 14 | + ArrayBytesCodecPartialDecodeMixin, |
| 15 | + ArrayBytesCodecPartialEncodeMixin, |
| 16 | + BytesBytesCodec, |
| 17 | +) |
12 | 18 | from zarr.codecs.bytes import BytesCodec |
13 | 19 | from zarr.codecs.gzip import GzipCodec |
14 | 20 | from zarr.codecs.transpose import TransposeCodec |
15 | 21 | from zarr.codecs.zstd import ZstdCodec |
16 | 22 | from zarr.core.codec_pipeline import FusedCodecPipeline |
17 | 23 | from zarr.core.config import config as zarr_config |
| 24 | +from zarr.registry import register_codec |
18 | 25 | from zarr.storage import MemoryStore, StorePath |
19 | 26 |
|
| 27 | +if TYPE_CHECKING: |
| 28 | + from zarr.core.array_spec import ArraySpec |
| 29 | + from zarr.core.buffer import Buffer, NDBuffer |
| 30 | + |
20 | 31 |
|
21 | 32 | @pytest.mark.parametrize( |
22 | 33 | "codecs", |
@@ -261,7 +272,7 @@ def test_chunk_transform_uses_runtime_prototype() -> None: |
261 | 272 | """ |
262 | 273 | from zarr.abc.codec import BytesBytesCodec |
263 | 274 | from zarr.core.array_spec import ArrayConfig, ArraySpec |
264 | | - from zarr.core.buffer import Buffer, BufferPrototype, default_buffer_prototype |
| 275 | + from zarr.core.buffer import BufferPrototype, default_buffer_prototype |
265 | 276 | from zarr.core.chunk_utils import ChunkTransform |
266 | 277 | from zarr.core.dtype import get_data_type_from_native_dtype |
267 | 278 |
|
@@ -831,3 +842,127 @@ def test_async_decode_encode_passes_through_none_chunks() -> None: |
831 | 842 | assert decoded[1] is None |
832 | 843 | assert decoded[0] is not None |
833 | 844 | np.testing.assert_array_equal(decoded[0].as_numpy_array(), data) |
| 845 | + |
| 846 | + |
| 847 | +# --------------------------------------------------------------------------- |
| 848 | +# Graceful fallback for partial-mixin codecs without private sync-partial hooks |
| 849 | +# |
| 850 | +# The public partial-decode/encode contract (`ArrayBytesCodecPartialDecodeMixin` |
| 851 | +# / `ArrayBytesCodecPartialEncodeMixin`) only requires the async |
| 852 | +# `_decode_partial_single` / `_encode_partial_single`. The fused pipeline must |
| 853 | +# route such codecs through its full-chunk sync path instead of asserting on |
| 854 | +# the private `_decode_partial_sync` / `_encode_partial_sync` hooks. The double |
| 855 | +# below is a minimal conforming implementer of that contract; it guards the |
| 856 | +# public extension API, so it must not grow the private sync-partial methods. |
| 857 | +# --------------------------------------------------------------------------- |
| 858 | + |
| 859 | + |
| 860 | +@dataclass(frozen=True) |
| 861 | +class PartialMixinCodec( |
| 862 | + ArrayBytesCodec, ArrayBytesCodecPartialDecodeMixin, ArrayBytesCodecPartialEncodeMixin |
| 863 | +): |
| 864 | + """Serializer with sync whole-chunk methods plus ONLY async partial methods. |
| 865 | +
|
| 866 | + This is the pre-fused public contract for partial-capable codecs: the |
| 867 | + mixins' `_decode_partial_single` / `_encode_partial_single`. It must not |
| 868 | + implement `_decode_partial_sync` / `_encode_partial_sync`. |
| 869 | + """ |
| 870 | + |
| 871 | + inner: BytesCodec = field(default_factory=BytesCodec) |
| 872 | + |
| 873 | + @classmethod |
| 874 | + def from_dict(cls, data: dict[str, Any]) -> PartialMixinCodec: |
| 875 | + return cls() |
| 876 | + |
| 877 | + def to_dict(self) -> dict[str, Any]: |
| 878 | + return {"name": "test-partial-mixin"} |
| 879 | + |
| 880 | + def evolve_from_array_spec(self, array_spec: ArraySpec) -> PartialMixinCodec: |
| 881 | + return replace(self, inner=self.inner.evolve_from_array_spec(array_spec)) |
| 882 | + |
| 883 | + def compute_encoded_size(self, input_byte_length: int, chunk_spec: ArraySpec) -> int: |
| 884 | + return self.inner.compute_encoded_size(input_byte_length, chunk_spec) |
| 885 | + |
| 886 | + def _decode_sync(self, chunk_bytes: Buffer, chunk_spec: ArraySpec) -> NDBuffer: |
| 887 | + return self.inner._decode_sync(chunk_bytes, chunk_spec) |
| 888 | + |
| 889 | + def _encode_sync(self, chunk_array: NDBuffer, chunk_spec: ArraySpec) -> Buffer | None: |
| 890 | + return self.inner._encode_sync(chunk_array, chunk_spec) |
| 891 | + |
| 892 | + async def _decode_single(self, chunk_bytes: Buffer, chunk_spec: ArraySpec) -> NDBuffer: |
| 893 | + return self._decode_sync(chunk_bytes, chunk_spec) |
| 894 | + |
| 895 | + async def _encode_single(self, chunk_array: NDBuffer, chunk_spec: ArraySpec) -> Buffer | None: |
| 896 | + return self._encode_sync(chunk_array, chunk_spec) |
| 897 | + |
| 898 | + async def _decode_partial_single( |
| 899 | + self, byte_getter: Any, selection: Any, chunk_spec: ArraySpec |
| 900 | + ) -> NDBuffer | None: |
| 901 | + chunk_bytes = await byte_getter.get(prototype=chunk_spec.prototype) |
| 902 | + if chunk_bytes is None: |
| 903 | + return None |
| 904 | + return self._decode_sync(chunk_bytes, chunk_spec)[selection] |
| 905 | + |
| 906 | + async def _encode_partial_single( |
| 907 | + self, byte_setter: Any, chunk_array: NDBuffer, selection: Any, chunk_spec: ArraySpec |
| 908 | + ) -> None: |
| 909 | + existing = await byte_setter.get(prototype=chunk_spec.prototype) |
| 910 | + if existing is None: |
| 911 | + full = chunk_spec.prototype.nd_buffer.create( |
| 912 | + shape=chunk_spec.shape, |
| 913 | + dtype=chunk_spec.dtype.to_native_dtype(), |
| 914 | + fill_value=chunk_spec.fill_value, |
| 915 | + ) |
| 916 | + else: |
| 917 | + full = self._decode_sync(existing, chunk_spec) |
| 918 | + full[selection] = chunk_array |
| 919 | + encoded = self._encode_sync(full, chunk_spec) |
| 920 | + assert encoded is not None |
| 921 | + await byte_setter.set(encoded) |
| 922 | + |
| 923 | + |
| 924 | +register_codec("test-partial-mixin", PartialMixinCodec) |
| 925 | + |
| 926 | +_FUSED = {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"} |
| 927 | +_BATCHED = {"codec_pipeline.path": "zarr.core.codec_pipeline.BatchedCodecPipeline"} |
| 928 | + |
| 929 | + |
| 930 | +@pytest.mark.filterwarnings("ignore::zarr.errors.UnstableSpecificationWarning") |
| 931 | +@pytest.mark.parametrize("dtype", ["uint8", "float64"]) |
| 932 | +def test_partial_mixin_codec_async_partial_only_round_trip(dtype: str) -> None: |
| 933 | + """A serializer advertising the partial mixins with only async partial |
| 934 | + methods must round-trip under the fused pipeline: full write, full read, |
| 935 | + partial read, partial write, plus cross-pipeline parity with |
| 936 | + BatchedCodecPipeline.""" |
| 937 | + data = np.arange(64, dtype=dtype).reshape(8, 8) |
| 938 | + |
| 939 | + with zarr_config.set(_FUSED): |
| 940 | + store = MemoryStore() |
| 941 | + arr = zarr.create_array( |
| 942 | + store, |
| 943 | + shape=(8, 8), |
| 944 | + chunks=(4, 4), |
| 945 | + dtype=dtype, |
| 946 | + serializer=PartialMixinCodec(), |
| 947 | + compressors=None, |
| 948 | + filters=None, |
| 949 | + fill_value=0, |
| 950 | + ) |
| 951 | + |
| 952 | + pipeline = arr._async_array.codec_pipeline |
| 953 | + assert isinstance(pipeline, FusedCodecPipeline) |
| 954 | + assert pipeline.supports_partial_decode |
| 955 | + assert pipeline.supports_partial_encode |
| 956 | + assert pipeline.sync_transform is not None |
| 957 | + |
| 958 | + arr[:] = data |
| 959 | + np.testing.assert_array_equal(arr[:], data) |
| 960 | + np.testing.assert_array_equal(arr[1:5, 2:7], data[1:5, 2:7]) |
| 961 | + |
| 962 | + expected = data.copy() |
| 963 | + expected[2:6, 1:3] = 7 |
| 964 | + arr[2:6, 1:3] = expected[2:6, 1:3] |
| 965 | + np.testing.assert_array_equal(arr[:], expected) |
| 966 | + |
| 967 | + with zarr_config.set(_BATCHED): |
| 968 | + np.testing.assert_array_equal(zarr.open_array(store, mode="r")[:], expected) |
0 commit comments