|
8 | 8 | import pytest |
9 | 9 |
|
10 | 10 | import zarr |
| 11 | +from zarr.abc.codec import BytesBytesCodec |
11 | 12 | from zarr.codecs.bytes import BytesCodec |
12 | 13 | from zarr.codecs.gzip import GzipCodec |
13 | 14 | from zarr.codecs.transpose import TransposeCodec |
@@ -643,6 +644,93 @@ def spy_write_sync(self: Any, *args: Any, **kwargs: Any) -> Any: |
643 | 644 | ) |
644 | 645 |
|
645 | 646 |
|
| 647 | +# --------------------------------------------------------------------------- |
| 648 | +# Async-only codecs inside a shard's inner codec chain |
| 649 | +# --------------------------------------------------------------------------- |
| 650 | + |
| 651 | + |
| 652 | +class _AsyncOnlyNoopCodec(BytesBytesCodec): # type: ignore[misc,unused-ignore] |
| 653 | + """A no-op BB codec implementing ONLY the async codec interface. |
| 654 | +
|
| 655 | + Deliberately does NOT satisfy `SupportsSyncCodec` (no `_decode_sync` / |
| 656 | + `_encode_sync`), modelling a third-party codec that predates the sync |
| 657 | + protocol. Class-level counters prove the codec actually ran. |
| 658 | + """ |
| 659 | + |
| 660 | + is_fixed_size = True |
| 661 | + encode_calls = 0 |
| 662 | + decode_calls = 0 |
| 663 | + |
| 664 | + def to_dict(self) -> dict[str, Any]: |
| 665 | + return {"name": "test-async-only-noop", "configuration": {}} |
| 666 | + |
| 667 | + @classmethod |
| 668 | + def from_dict(cls, data: dict[str, Any]) -> _AsyncOnlyNoopCodec: |
| 669 | + return cls() |
| 670 | + |
| 671 | + def compute_encoded_size(self, input_byte_length: int, _spec: Any) -> int: |
| 672 | + return input_byte_length |
| 673 | + |
| 674 | + async def _encode_single(self, chunk_bytes: Any, chunk_spec: Any) -> Any: |
| 675 | + type(self).encode_calls += 1 |
| 676 | + return chunk_bytes |
| 677 | + |
| 678 | + async def _decode_single(self, chunk_bytes: Any, chunk_spec: Any) -> Any: |
| 679 | + type(self).decode_calls += 1 |
| 680 | + return chunk_bytes |
| 681 | + |
| 682 | + |
| 683 | +def test_sharded_roundtrip_with_async_only_inner_codec() -> None: |
| 684 | + """A sharded array whose INNER codec chain contains an async-only codec |
| 685 | + round-trips under FusedCodecPipeline (full write, partial write, full read, |
| 686 | + partial read). |
| 687 | +
|
| 688 | + Regression: the pipeline's top-level guard (evolve_from_array_spec -> |
| 689 | + sync_transform=None) only inspected the top-level chain. ShardingCodec |
| 690 | + structurally satisfies SupportsSyncCodec, so a sync transform was built and |
| 691 | + the sync fast path dove into ShardingCodec's sync shard paths, which raised |
| 692 | + TypeError from the inner ChunkTransform. The pipeline must instead decline |
| 693 | + the sync fast path and fall back to the async inner pipeline, like |
| 694 | + BatchedCodecPipeline. |
| 695 | + """ |
| 696 | + _AsyncOnlyNoopCodec.encode_calls = 0 |
| 697 | + _AsyncOnlyNoopCodec.decode_calls = 0 |
| 698 | + |
| 699 | + with zarr_config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}): |
| 700 | + store = MemoryStore() |
| 701 | + arr = zarr.create_array( |
| 702 | + store=store, |
| 703 | + shape=(16, 16), |
| 704 | + shards=(8, 8), |
| 705 | + chunks=(4, 4), |
| 706 | + dtype="int32", |
| 707 | + compressors=[_AsyncOnlyNoopCodec()], |
| 708 | + fill_value=-1, |
| 709 | + ) |
| 710 | + assert isinstance(arr._async_array.codec_pipeline, FusedCodecPipeline) |
| 711 | + |
| 712 | + data = np.arange(256, dtype="int32").reshape(16, 16) |
| 713 | + arr[:] = data # full write |
| 714 | + np.testing.assert_array_equal(arr[:], data) # full read |
| 715 | + np.testing.assert_array_equal(arr[2:11, 3:14], data[2:11, 3:14]) # partial read |
| 716 | + |
| 717 | + arr[5:7, 5:13] = 0 # partial write (read-merge-write of existing shards) |
| 718 | + data[5:7, 5:13] = 0 |
| 719 | + np.testing.assert_array_equal(arr[:], data) |
| 720 | + |
| 721 | + assert _AsyncOnlyNoopCodec.encode_calls > 0, "async-only inner codec never encoded" |
| 722 | + assert _AsyncOnlyNoopCodec.decode_calls > 0, "async-only inner codec never decoded" |
| 723 | + |
| 724 | + # The stored bytes are valid for the default pipeline too: read them back |
| 725 | + # under BatchedCodecPipeline (default codec_pipeline.path). Opening from |
| 726 | + # metadata needs the codec name in the registry. |
| 727 | + from zarr.registry import register_codec |
| 728 | + |
| 729 | + register_codec("test-async-only-noop", _AsyncOnlyNoopCodec) |
| 730 | + reread = zarr.open_array(store=store, mode="r") |
| 731 | + np.testing.assert_array_equal(reread[:], data) |
| 732 | + |
| 733 | + |
646 | 734 | # --------------------------------------------------------------------------- |
647 | 735 | # AsyncChunkTransform: the async per-chunk codec chain used on the async |
648 | 736 | # fallback path. It is the async mirror of ChunkTransform, so it must produce |
|
0 commit comments