Skip to content

Commit bbe27d7

Browse files
authored
fix: byte-order handling for structured dtypes in the bytes codec (#4142)
* fix: byte-order handling for structured dtypes in the bytes codec The bytes codec neither byte-swapped structured-dtype fields to its configured endian on encode (numpy reports byteorder '|' for void dtypes, so the top-level byteorder comparison never detected a mismatch) nor honored its endian when decoding, silently corrupting any structured data whose field byte order differed from the stored one (e.g. virtual references to external big-endian data). Encode now detects byte-order mismatches by comparing full dtypes via newbyteorder, and decode reinterprets raw bytes in the stored byte order before converting to the data type's declared byte order, so the stored layout (codec state) and the in-memory layout (array data type) are independent. Closes #4141 Assisted-by: ClaudeCode:claude-fable-5 * test: fold structured byte-order cases into existing bytes codec tests Extend test_endian's parametrization with structured dtypes and test_bytes_codec_sync_roundtrip with endian/dtype parametrization plus stored-layout and decoded-dtype assertions, instead of adding parallel test functions for the same properties. Assisted-by: ClaudeCode:claude-fable-5 * refactor: rename stored_dtype to view_dtype in BytesCodec decode The variable is the dtype used to view the raw chunk bytes (byte order from the codec's endian configuration), not a property of the stored data or of the returned buffer, which always carries the array's declared dtype. Assisted-by: ClaudeCode:claude-fable-5 * docs: note that the decode-side byte-order conversion copies the chunk Assisted-by: ClaudeCode:claude-fable-5
1 parent de2cce1 commit bbe27d7

3 files changed

Lines changed: 80 additions & 23 deletions

File tree

changes/4141.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix silent byte-order corruption for structured dtypes with the `bytes` codec: multi-byte fields are now byte-swapped to the codec's configured `endian` on write and decoded honoring it on read, so non-native-endian structured data (e.g. big-endian fields, as produced by virtual references to external data) round-trips correctly.

src/zarr/codecs/bytes.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,28 @@ def _decode_sync(
100100
chunk_spec: ArraySpec,
101101
) -> NDBuffer:
102102
endian_str = self.endian
103+
dtype = chunk_spec.dtype.to_native_dtype()
104+
# The byte order of the stored data is set by this codec's `endian`
105+
# configuration; the byte order of the decoded array is set by the array's
106+
# data type. The two are independent: the raw bytes are viewed with a dtype
107+
# in the stored byte order, then converted to the declared dtype if needed.
103108
if isinstance(chunk_spec.dtype, HasEndianness):
104-
dtype = replace(chunk_spec.dtype, endianness=endian_str).to_native_dtype() # type: ignore[call-arg]
109+
view_dtype = replace(chunk_spec.dtype, endianness=endian_str).to_native_dtype() # type: ignore[call-arg]
110+
elif isinstance(chunk_spec.dtype, Struct) and endian_str is not None:
111+
# Per the struct data type spec, all multi-byte fields are stored in the
112+
# byte order configured on this codec.
113+
view_dtype = dtype.newbyteorder(endian_str)
105114
else:
106-
dtype = chunk_spec.dtype.to_native_dtype()
115+
view_dtype = dtype
107116
as_array_like = chunk_bytes.as_array_like()
108117
chunk_array = chunk_spec.prototype.nd_buffer.from_ndarray_like(
109-
as_array_like.view(dtype=dtype) # type: ignore[attr-defined]
118+
as_array_like.view(dtype=view_dtype) # type: ignore[attr-defined]
110119
)
120+
if view_dtype != dtype:
121+
# This byte-swapping conversion copies the chunk. The dtype inequality
122+
# guard keeps the common case, where the stored and declared byte orders
123+
# already match, on the zero-copy view path above.
124+
chunk_array = chunk_array.astype(dtype)
111125

112126
# ensure correct chunk shape
113127
if chunk_array.shape != chunk_spec.shape:
@@ -129,13 +143,14 @@ def _encode_sync(
129143
chunk_spec: ArraySpec,
130144
) -> Buffer | None:
131145
assert isinstance(chunk_array, NDBuffer)
132-
if (
133-
chunk_array.dtype.itemsize > 1
134-
and self.endian is not None
135-
and self.endian != chunk_array.byteorder
136-
):
146+
if chunk_array.dtype.itemsize > 1 and self.endian is not None:
147+
# Compare full dtypes rather than the top-level byteorder: numpy reports
148+
# byteorder '|' for structured dtypes even when their fields are
149+
# byte-order-sensitive, so newbyteorder is the only reliable way to
150+
# detect (and normalize) a byte-order mismatch.
137151
new_dtype = chunk_array.dtype.newbyteorder(self.endian)
138-
chunk_array = chunk_array.astype(new_dtype)
152+
if new_dtype != chunk_array.dtype:
153+
chunk_array = chunk_array.astype(new_dtype)
139154

140155
nd_array = chunk_array.as_ndarray_like()
141156
# Flatten the nd-array (only copy if needed) and reinterpret as bytes

tests/test_codecs/test_bytes.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,29 +33,50 @@
3333

3434

3535
@pytest.mark.parametrize("store", ["local", "memory"], indirect=["store"])
36-
@pytest.mark.parametrize("input_dtype", [">u2", "<u2"])
36+
@pytest.mark.parametrize(
37+
"input_dtype",
38+
[
39+
">u2",
40+
"<u2",
41+
[("flux", ">f4"), ("mask", ">i4")],
42+
[("flux", "<f4"), ("mask", "<i4")],
43+
],
44+
ids=["big-scalar", "little-scalar", "big-struct", "little-struct"],
45+
)
3746
@pytest.mark.parametrize("store_endian", ["big", "little"])
3847
async def test_endian(
3948
store: Store,
40-
input_dtype: Literal[">u2", "<u2"],
49+
input_dtype: str | list[tuple[str, str]],
4150
store_endian: Literal["big", "little"],
4251
) -> None:
4352
"""
4453
The `bytes` codec stores multi-byte data in the byte order configured on the
4554
codec, regardless of the input array's byte order, and reads it back to the
46-
original values. The input-dtype/store-endian cross-product exercises the
47-
encode-side byteswap (input byte order != store byte order) and the no-op
48-
case alike. Compression is disabled so the stored chunk is the codec's raw
49-
output and its byte layout can be asserted directly.
55+
original values. For structured dtypes this applies to every multi-byte
56+
field, per the `struct` data type spec; the struct cases guard against the
57+
endianness bugs from
58+
https://github.com/zarr-developers/zarr-python/issues/4141, where the
59+
encode path never byte-swapped struct fields (numpy reports byteorder '|'
60+
for void dtypes) and the decode path ignored the codec's endian entirely.
61+
The input-dtype/store-endian cross-product exercises the encode-side
62+
byteswap (input byte order != store byte order) and the no-op case alike.
63+
Compression is disabled so the stored chunk is the codec's raw output and
64+
its byte layout can be asserted directly.
5065
"""
51-
data = np.arange(0, 256, dtype=input_dtype).reshape((16, 16))
66+
dtype = np.dtype(input_dtype)
67+
if dtype.fields is None:
68+
data = np.arange(0, 256, dtype=dtype).reshape((16, 16))
69+
else:
70+
data = np.zeros((16, 16), dtype=dtype)
71+
data["flux"] = np.arange(0, 256).reshape((16, 16))
72+
data["mask"] = np.arange(256, 512).reshape((16, 16))
5273
path = "endian"
5374
spath = StorePath(store, path)
5475
a = await zarr.api.asynchronous.create_array(
5576
spath,
5677
shape=data.shape,
5778
chunks=(16, 16),
58-
dtype="uint16",
79+
dtype=dtype,
5980
fill_value=0,
6081
compressors=None,
6182
serializer=BytesCodec(endian=store_endian),
@@ -66,8 +87,7 @@ async def test_endian(
6687
# The stored chunk is laid out in the byte order configured on the codec.
6788
stored = await store.get(f"{path}/c/0/0", prototype=default_buffer_prototype())
6889
assert stored is not None
69-
expected_dtype = ">u2" if store_endian == "big" else "<u2"
70-
assert stored.to_bytes() == data.astype(expected_dtype).tobytes()
90+
assert stored.to_bytes() == data.astype(dtype.newbyteorder(store_endian)).tobytes()
7191

7292
# ... and the data reads back to the original values.
7393
readback_data = await _AsyncArrayProxy(a)[:, :].get()
@@ -78,9 +98,27 @@ def test_bytes_codec_supports_sync() -> None:
7898
assert isinstance(BytesCodec(), SupportsSyncCodec)
7999

80100

81-
def test_bytes_codec_sync_roundtrip() -> None:
82-
codec = BytesCodec()
83-
arr = np.arange(100, dtype="float64")
101+
@pytest.mark.parametrize("endian", ENDIAN)
102+
@pytest.mark.parametrize(
103+
"native_dtype",
104+
[np.dtype("float64"), np.dtype(">u2"), np.dtype([("a", ">f4"), ("b", "<i4")])],
105+
ids=["native-scalar", "big-scalar", "mixed-endian-struct"],
106+
)
107+
def test_bytes_codec_sync_roundtrip(endian: EndianLiteral, native_dtype: np.dtype[Any]) -> None:
108+
"""
109+
The synchronous encode/decode path round-trips data, and the two byte
110+
orders involved are independent: the codec's `endian` configuration governs
111+
only the stored byte layout (every multi-byte value, including struct
112+
fields, is laid out in the codec's byte order regardless of the input
113+
array's byte order), while the decoded buffer's byte order is governed by
114+
the array's data type regardless of the codec's. The mixed-endian struct
115+
case pins that per-field byte order of the in-memory dtype survives a
116+
roundtrip through a single stored byte order.
117+
"""
118+
if native_dtype.fields is None:
119+
arr = np.arange(100, dtype=native_dtype)
120+
else:
121+
arr = np.array([(1.5, 2), (3.5, 4), (5.5, 6), (7.5, 8)], dtype=native_dtype)
84122
zdtype = get_data_type_from_native_dtype(arr.dtype)
85123
spec = ArraySpec(
86124
shape=arr.shape,
@@ -91,11 +129,14 @@ def test_bytes_codec_sync_roundtrip() -> None:
91129
)
92130
nd_buf: NDBuffer = default_buffer_prototype().nd_buffer.from_numpy_array(arr)
93131

94-
codec = codec.evolve_from_array_spec(spec)
132+
codec = BytesCodec(endian=endian).evolve_from_array_spec(spec)
95133

96134
encoded = codec._encode_sync(nd_buf, spec)
97135
assert encoded is not None
136+
assert encoded.to_bytes() == arr.astype(native_dtype.newbyteorder(endian)).tobytes()
137+
98138
decoded = codec._decode_sync(encoded, spec)
139+
assert decoded.dtype == zdtype.to_native_dtype()
99140
np.testing.assert_array_equal(arr, decoded.as_numpy_array())
100141

101142

0 commit comments

Comments
 (0)