From 597d2d5e774e0e6b41eff3a99139b471f88a381b Mon Sep 17 00:00:00 2001 From: barlowa124 Date: Thu, 8 Oct 2026 00:06:46 -0400 Subject: [PATCH 1/6] fix: preserve non-canonical NaN payloads and distinguish -0.0 in v3 metadata float_from_json_v3 decoded hex strings through Python float64, which cannot carry a float16 payload and risks narrowing float32 payloads; decode into a same-width NumPy scalar via np.frombuffer instead. float_to_json_v3 flattened every NaN to "NaN" on write; emit the hex bit pattern for NaNs that are not the canonical positive NaN so the document round-trips the payload it parsed. ArrayV3Metadata.__eq__ compared to_dict() dictionaries, under which -0.0 == 0.0 and (before this change) any NaN payload == "NaN" even though the documents serialize differently. Compare the canonical serialized JSON text instead, which is also the exact form __hash__ already hashes, making the two consistent by construction. Partial fix for https://github.com/zarr-developers/zarr-python/issues/4453 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/zarr/core/dtype/npy/common.py | 40 +++++++++++++++++++------ src/zarr/core/metadata/v3.py | 7 ++++- tests/test_dtype/test_npy/test_float.py | 16 ++++++++++ tests/test_metadata/test_v3.py | 28 +++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/zarr/core/dtype/npy/common.py b/src/zarr/core/dtype/npy/common.py index f413f5f678..ed3c4e36a9 100644 --- a/src/zarr/core/dtype/npy/common.py +++ b/src/zarr/core/dtype/npy/common.py @@ -1,7 +1,6 @@ from __future__ import annotations import base64 -import struct import sys from collections.abc import Sequence from typing import ( @@ -15,6 +14,7 @@ SupportsIndex, SupportsInt, TypeGuard, + cast, ) import numpy as np @@ -162,7 +162,7 @@ def float_from_json_v2(data: JSONFloatV2) -> float: return float(data) -def float_from_json_v3(data: JSONFloatV3) -> float: +def float_from_json_v3(data: JSONFloatV3) -> float | np.floating[Any]: """ Convert a JSON float to a float (v3). @@ -182,6 +182,9 @@ def float_from_json_v3(data: JSONFloatV3) -> float: "...for float32, "NaN" is equivalent to "0x7fc00000". This representation is the only way to specify a NaN value other than the specific NaN value denoted by "NaN"." + + Hex strings decode into a NumPy scalar of the matching width rather than a + Python float, so a non-canonical NaN payload survives the parse. """ if isinstance(data, str): @@ -194,18 +197,21 @@ def float_from_json_v3(data: JSONFloatV3) -> float: ) raise ValueError(msg) if len(data[2:]) == 4: - dtype_code = ">e" + dtype_code = ">f2" elif len(data[2:]) == 8: - dtype_code = ">f" + dtype_code = ">f4" elif len(data[2:]) == 16: - dtype_code = ">d" + dtype_code = ">f8" else: msg = ( f"Invalid hexadecimal float value: {data!r}. " "Expected the '0x' prefix to be followed by 4, 8, or 16 numeral characters" ) raise ValueError(msg) - return float(struct.unpack(dtype_code, bytes.fromhex(data[2:]))[0]) + return cast( + np.floating[Any], + np.frombuffer(bytes.fromhex(data[2:]), dtype=dtype_code)[0], + ) return float_from_json_v2(data) @@ -287,9 +293,25 @@ def float_to_json_v3(data: float | np.floating[Any]) -> JSONFloatV3: ------- JSONFloat The JSON representation of the float. - """ - # v3 can in principle handle distinct NaN values, but numpy does not represent these explicitly - # so we just reuse the v2 routine here + + Notes + ----- + ``"NaN"`` denotes the canonical NaN only. A NaN carrying any other + payload (or sign) is written as its hexadecimal bit pattern, per the + spec's hex-bits encoding (``"0x7fc00001"`` for a float32 NaN with + payload 1), which is the only way to name a non-canonical NaN. + """ + if np.isnan(data) and isinstance(data, np.floating): + uint_dtype = {2: np.uint16, 4: np.uint32, 8: np.uint64}.get(data.dtype.itemsize) + if uint_dtype is not None: + bits = int(np.asarray(data).view(uint_dtype)) + canonical = { + 2: 0x7E00, + 4: 0x7FC00000, + 8: 0x7FF8000000000000, + }[data.dtype.itemsize] + if bits != canonical: + return f"0x{bits:0{data.dtype.itemsize * 2}x}" return float_to_json_v2(data) diff --git a/src/zarr/core/metadata/v3.py b/src/zarr/core/metadata/v3.py index 2d5d169e6b..24d9543363 100644 --- a/src/zarr/core/metadata/v3.py +++ b/src/zarr/core/metadata/v3.py @@ -756,9 +756,14 @@ def __eq__(self, other: object) -> bool: # The default dataclass __eq__ compares fields directly, which is wrong for a NaN # fill_value: NaN != NaN under IEEE 754. Comparing the JSON-serialized form instead # treats matching NaN (and inf) fill values as equal. See issue #2929. + # The comparison uses the serialized JSON *text*: dict equality conflates + # -0.0 and 0.0, and a non-canonical NaN payload with "NaN", though the + # documents write back differently. See issue #4453. if not isinstance(other, ArrayV3Metadata): return NotImplemented - return self.to_dict() == other.to_dict() + return json.dumps(self.to_dict(), sort_keys=True) == json.dumps( + other.to_dict(), sort_keys=True + ) def __hash__(self) -> int: # Hash the JSON-serialized form to stay consistent with __eq__: equal metadata diff --git a/tests/test_dtype/test_npy/test_float.py b/tests/test_dtype/test_npy/test_float.py index c8418f922e..f2108e5e72 100644 --- a/tests/test_dtype/test_npy/test_float.py +++ b/tests/test_dtype/test_npy/test_float.py @@ -223,3 +223,19 @@ def test_string_float_from_json_scalar() -> None: # Test that it works for v2 format too result = dtype_instance.from_json_scalar("1.5", zarr_format=2) assert result == np.float32(1.5) + + +def test_noncanonical_nan_serializes_as_hex() -> None: + """A NaN carrying a non-canonical payload or sign writes back as its hex + bits; ``"NaN"`` denotes the canonical NaN only. Regression test for + https://github.com/zarr-developers/zarr-python/issues/4453 item 3.""" + for dtype, hex_value in [ + (Float16(), "0x7fc1"), + (Float32(), "0x7fc00001"), + (Float32(), "0xffc00000"), + (Float64(), "0x7ff8000000000001"), + ]: + scalar = dtype.from_json_scalar(hex_value, zarr_format=3) + assert dtype.to_json_scalar(scalar, zarr_format=3) == hex_value + canonical = dtype.from_json_scalar("NaN", zarr_format=3) + assert dtype.to_json_scalar(canonical, zarr_format=3) == "NaN" diff --git a/tests/test_metadata/test_v3.py b/tests/test_metadata/test_v3.py index 94240c36b9..8687888a6d 100644 --- a/tests/test_metadata/test_v3.py +++ b/tests/test_metadata/test_v3.py @@ -491,3 +491,31 @@ def test_group_metadata_to_dict_consolidated(attributes: dict[str, Any] | None) }, }, } + + +def test_eq_distinguishes_signed_zero_fill_value() -> None: + """Documents with fill_value -0.0 and 0.0 write back differently and must + not compare equal. Regression test for issue #4453 item 3.""" + neg = ArrayV3Metadata.from_dict( + minimal_metadata_dict_v3(data_type="float64", fill_value=-0.0) # type: ignore[arg-type] + ) + pos = ArrayV3Metadata.from_dict( + minimal_metadata_dict_v3(data_type="float64", fill_value=0.0) # type: ignore[arg-type] + ) + assert neg != pos + assert hash(neg) != hash(pos) + + +def test_nan_payload_fill_value_round_trips() -> None: + """A non-canonical NaN payload survives a document round trip as its hex + bits, and does not compare equal to the canonical NaN document.""" + payload = ArrayV3Metadata.from_dict( + minimal_metadata_dict_v3(data_type="float32", fill_value="0x7fc00001") # type: ignore[arg-type] + ) + canonical = ArrayV3Metadata.from_dict( + minimal_metadata_dict_v3(data_type="float32", fill_value="NaN") # type: ignore[arg-type] + ) + assert payload.to_dict()["fill_value"] == "0x7fc00001" + assert canonical.to_dict()["fill_value"] == "NaN" + assert payload != canonical + assert hash(payload) != hash(canonical) From 08e7d6acc7bd358ef7ee6ecfb8e2c577efbd6ffd Mon Sep 17 00:00:00 2001 From: barlowa124 Date: Thu, 8 Oct 2026 00:08:03 -0400 Subject: [PATCH 2/6] docs: add changelog entry for #4486 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- changes/4486.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4486.bugfix.md diff --git a/changes/4486.bugfix.md b/changes/4486.bugfix.md new file mode 100644 index 0000000000..12632d6bcb --- /dev/null +++ b/changes/4486.bugfix.md @@ -0,0 +1 @@ +Preserve non-canonical NaN payloads in v3 fill values (a hex-encoded NaN now round-trips its bit pattern instead of flattening to "NaN", and hex strings decode into same-width NumPy scalars so float16 payloads survive), and stop `ArrayV3Metadata` equality from conflating -0.0 with 0.0 or NaN payloads with "NaN". Partial fix for issue #4453. From fff5fca68faf4e3ded4cebaf4d7860c4eee5f51c Mon Sep 17 00:00:00 2001 From: barlowa124 Date: Thu, 8 Oct 2026 00:45:41 -0400 Subject: [PATCH 3/6] test: annotate float dtype cases for strict mypy Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_dtype/test_npy/test_float.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_dtype/test_npy/test_float.py b/tests/test_dtype/test_npy/test_float.py index f2108e5e72..0626277528 100644 --- a/tests/test_dtype/test_npy/test_float.py +++ b/tests/test_dtype/test_npy/test_float.py @@ -1,9 +1,11 @@ from __future__ import annotations +from typing import Any + import numpy as np from tests.test_dtype.test_wrapper import BaseTestZDType -from zarr.core.dtype.npy.float import Float16, Float32, Float64 +from zarr.core.dtype.npy.float import BaseFloat, Float16, Float32, Float64 class _BaseTestFloat(BaseTestZDType): @@ -229,12 +231,13 @@ def test_noncanonical_nan_serializes_as_hex() -> None: """A NaN carrying a non-canonical payload or sign writes back as its hex bits; ``"NaN"`` denotes the canonical NaN only. Regression test for https://github.com/zarr-developers/zarr-python/issues/4453 item 3.""" - for dtype, hex_value in [ + cases: list[tuple[BaseFloat[Any, Any], str]] = [ (Float16(), "0x7fc1"), (Float32(), "0x7fc00001"), (Float32(), "0xffc00000"), (Float64(), "0x7ff8000000000001"), - ]: + ] + for dtype, hex_value in cases: scalar = dtype.from_json_scalar(hex_value, zarr_format=3) assert dtype.to_json_scalar(scalar, zarr_format=3) == hex_value canonical = dtype.from_json_scalar("NaN", zarr_format=3) From dae238a6266618eefa782c1fc5790767490e49d9 Mon Sep 17 00:00:00 2001 From: barlowa124 Date: Thu, 8 Oct 2026 15:25:37 -0400 Subject: [PATCH 4/6] fix: treat sign-set canonical-payload NaN as canonical in v3 metadata The strict bits==canonical check serialized float32 0xffc00000 as hex, but the canonical NaN designation covers the payload, not the sign; mask the sign bit so 0xffc00000 writes as "NaN" like the canonical form. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/zarr/core/dtype/npy/common.py | 15 +++++++++------ tests/test_dtype/test_npy/test_float.py | 11 ++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/zarr/core/dtype/npy/common.py b/src/zarr/core/dtype/npy/common.py index ed3c4e36a9..6cbed91e3a 100644 --- a/src/zarr/core/dtype/npy/common.py +++ b/src/zarr/core/dtype/npy/common.py @@ -296,10 +296,11 @@ def float_to_json_v3(data: float | np.floating[Any]) -> JSONFloatV3: Notes ----- - ``"NaN"`` denotes the canonical NaN only. A NaN carrying any other - payload (or sign) is written as its hexadecimal bit pattern, per the - spec's hex-bits encoding (``"0x7fc00001"`` for a float32 NaN with - payload 1), which is the only way to name a non-canonical NaN. + ``"NaN"`` denotes the canonical NaN, meaning the quiet NaN payload with + no extra payload bits; the sign bit is not significant for this check. + A NaN carrying a non-canonical payload is written as its hexadecimal bit + pattern, per the spec's hex-bits encoding (``"0x7fc00001"`` for a float32 + NaN with payload 1), which is the only way to name a non-canonical NaN. """ if np.isnan(data) and isinstance(data, np.floating): uint_dtype = {2: np.uint16, 4: np.uint32, 8: np.uint64}.get(data.dtype.itemsize) @@ -310,8 +311,10 @@ def float_to_json_v3(data: float | np.floating[Any]) -> JSONFloatV3: 4: 0x7FC00000, 8: 0x7FF8000000000000, }[data.dtype.itemsize] - if bits != canonical: - return f"0x{bits:0{data.dtype.itemsize * 2}x}" + sign = 1 << (data.dtype.itemsize * 8 - 1) + if bits & ~sign == canonical: + return float_to_json_v2(data) + return f"0x{bits:0{data.dtype.itemsize * 2}x}" return float_to_json_v2(data) diff --git a/tests/test_dtype/test_npy/test_float.py b/tests/test_dtype/test_npy/test_float.py index 0626277528..f8aed5c5ab 100644 --- a/tests/test_dtype/test_npy/test_float.py +++ b/tests/test_dtype/test_npy/test_float.py @@ -228,13 +228,14 @@ def test_string_float_from_json_scalar() -> None: def test_noncanonical_nan_serializes_as_hex() -> None: - """A NaN carrying a non-canonical payload or sign writes back as its hex - bits; ``"NaN"`` denotes the canonical NaN only. Regression test for + """A NaN carrying a non-canonical payload writes back as its hex + bits; ``"NaN"`` denotes the canonical NaN payload regardless of sign. + Regression test for https://github.com/zarr-developers/zarr-python/issues/4453 item 3.""" cases: list[tuple[BaseFloat[Any, Any], str]] = [ (Float16(), "0x7fc1"), (Float32(), "0x7fc00001"), - (Float32(), "0xffc00000"), + (Float32(), "0xffc00001"), (Float64(), "0x7ff8000000000001"), ] for dtype, hex_value in cases: @@ -242,3 +243,7 @@ def test_noncanonical_nan_serializes_as_hex() -> None: assert dtype.to_json_scalar(scalar, zarr_format=3) == hex_value canonical = dtype.from_json_scalar("NaN", zarr_format=3) assert dtype.to_json_scalar(canonical, zarr_format=3) == "NaN" + # Sign alone does not make a NaN non-canonical: 0xffc00000 is the + # negative-sign form of the canonical float32 payload. + signed = Float32().from_json_scalar("0xffc00000", zarr_format=3) + assert Float32().to_json_scalar(signed, zarr_format=3) == "NaN" From 77bde93f3a919e450ab32691c2e334e2839fcaed Mon Sep 17 00:00:00 2001 From: Angus Date: Fri, 9 Oct 2026 11:23:48 -0400 Subject: [PATCH 5/6] address review: sign-aware canonical NaN, numpy-derived canonical bits, key-order-free metadata equality --- changes/4486.bugfix.md | 12 ++++++- src/zarr/core/dtype/npy/common.py | 19 ++++------ src/zarr/core/metadata/v3.py | 6 ++-- tests/test_dtype/test_npy/test_float.py | 47 ++++++++++++++++--------- 4 files changed, 51 insertions(+), 33 deletions(-) diff --git a/changes/4486.bugfix.md b/changes/4486.bugfix.md index 12632d6bcb..ad6752b628 100644 --- a/changes/4486.bugfix.md +++ b/changes/4486.bugfix.md @@ -1 +1,11 @@ -Preserve non-canonical NaN payloads in v3 fill values (a hex-encoded NaN now round-trips its bit pattern instead of flattening to "NaN", and hex strings decode into same-width NumPy scalars so float16 payloads survive), and stop `ArrayV3Metadata` equality from conflating -0.0 with 0.0 or NaN payloads with "NaN". Partial fix for issue #4453. +Preserve non-canonical NaN payloads in v3 fill values. Only the exact +canonical NaN bit pattern, sign bit included, serializes as `NaN`; every +other NaN, including the negative-sign form of the canonical payload, +writes back as its hex bits, and hex strings decode into same-width +NumPy scalars so float16 payloads survive. `ArrayV3Metadata` equality +now compares the serialized JSON text without sorting keys, so attribute +dictionaries with mixed key types no longer raise `TypeError`; as a +consequence of comparing JSON text, attributes that differ only in JSON +spelling, such as `1` and `1.0`, no longer compare equal. This also +stops equality from conflating `-0.0` with `0.0`. Partial fix for issue +#4453. diff --git a/src/zarr/core/dtype/npy/common.py b/src/zarr/core/dtype/npy/common.py index 6cbed91e3a..d2b6904565 100644 --- a/src/zarr/core/dtype/npy/common.py +++ b/src/zarr/core/dtype/npy/common.py @@ -296,23 +296,18 @@ def float_to_json_v3(data: float | np.floating[Any]) -> JSONFloatV3: Notes ----- - ``"NaN"`` denotes the canonical NaN, meaning the quiet NaN payload with - no extra payload bits; the sign bit is not significant for this check. - A NaN carrying a non-canonical payload is written as its hexadecimal bit - pattern, per the spec's hex-bits encoding (``"0x7fc00001"`` for a float32 - NaN with payload 1), which is the only way to name a non-canonical NaN. + ``"NaN"`` denotes exactly the canonical NaN bit pattern, sign bit + included. Any other NaN, including the negative-sign form of the + canonical payload, is written as its hexadecimal bit pattern per the + spec's hex-bits encoding (``"0x7fc00001"`` for a float32 NaN with + payload 1), which is the only way to name a non-canonical NaN. """ if np.isnan(data) and isinstance(data, np.floating): uint_dtype = {2: np.uint16, 4: np.uint32, 8: np.uint64}.get(data.dtype.itemsize) if uint_dtype is not None: bits = int(np.asarray(data).view(uint_dtype)) - canonical = { - 2: 0x7E00, - 4: 0x7FC00000, - 8: 0x7FF8000000000000, - }[data.dtype.itemsize] - sign = 1 << (data.dtype.itemsize * 8 - 1) - if bits & ~sign == canonical: + canonical = int(np.asarray(np.nan, dtype=data.dtype).view(uint_dtype)) + if bits == canonical: return float_to_json_v2(data) return f"0x{bits:0{data.dtype.itemsize * 2}x}" return float_to_json_v2(data) diff --git a/src/zarr/core/metadata/v3.py b/src/zarr/core/metadata/v3.py index 24d9543363..176c7834f1 100644 --- a/src/zarr/core/metadata/v3.py +++ b/src/zarr/core/metadata/v3.py @@ -761,14 +761,12 @@ def __eq__(self, other: object) -> bool: # documents write back differently. See issue #4453. if not isinstance(other, ArrayV3Metadata): return NotImplemented - return json.dumps(self.to_dict(), sort_keys=True) == json.dumps( - other.to_dict(), sort_keys=True - ) + return json.dumps(self.to_dict()) == json.dumps(other.to_dict()) def __hash__(self) -> int: # Hash the JSON-serialized form to stay consistent with __eq__: equal metadata # must hash equally, which a field-based hash violates for a NaN fill_value. - return hash(json.dumps(self.to_dict(), sort_keys=True)) + return hash(json.dumps(self.to_dict())) def update_shape(self, shape: tuple[int, ...]) -> Self: chunk_grid = self.chunk_grid diff --git a/tests/test_dtype/test_npy/test_float.py b/tests/test_dtype/test_npy/test_float.py index f8aed5c5ab..c1ec1c81f9 100644 --- a/tests/test_dtype/test_npy/test_float.py +++ b/tests/test_dtype/test_npy/test_float.py @@ -3,6 +3,7 @@ from typing import Any import numpy as np +import pytest from tests.test_dtype.test_wrapper import BaseTestZDType from zarr.core.dtype.npy.float import BaseFloat, Float16, Float32, Float64 @@ -227,23 +228,37 @@ def test_string_float_from_json_scalar() -> None: assert result == np.float32(1.5) -def test_noncanonical_nan_serializes_as_hex() -> None: - """A NaN carrying a non-canonical payload writes back as its hex - bits; ``"NaN"`` denotes the canonical NaN payload regardless of sign. - Regression test for - https://github.com/zarr-developers/zarr-python/issues/4453 item 3.""" - cases: list[tuple[BaseFloat[Any, Any], str]] = [ +@pytest.mark.parametrize( + ("dtype", "hex_value"), + [ (Float16(), "0x7fc1"), (Float32(), "0x7fc00001"), + (Float32(), "0xffc00000"), (Float32(), "0xffc00001"), (Float64(), "0x7ff8000000000001"), - ] - for dtype, hex_value in cases: - scalar = dtype.from_json_scalar(hex_value, zarr_format=3) - assert dtype.to_json_scalar(scalar, zarr_format=3) == hex_value - canonical = dtype.from_json_scalar("NaN", zarr_format=3) - assert dtype.to_json_scalar(canonical, zarr_format=3) == "NaN" - # Sign alone does not make a NaN non-canonical: 0xffc00000 is the - # negative-sign form of the canonical float32 payload. - signed = Float32().from_json_scalar("0xffc00000", zarr_format=3) - assert Float32().to_json_scalar(signed, zarr_format=3) == "NaN" + (Float64(), "0xfff8000000000000"), + ], +) +def test_noncanonical_nan_serializes_as_hex( + dtype: BaseFloat[Any, Any], hex_value: str +) -> None: + """Only the exact canonical NaN bit pattern writes back as ``"NaN"``; + every other NaN, including the negative-sign form of the canonical + payload, writes back as its hex bits. Regression test for + https://github.com/zarr-developers/zarr-python/issues/4453 item 3.""" + scalar = dtype.from_json_scalar(hex_value, zarr_format=3) + assert dtype.to_json_scalar(scalar, zarr_format=3) == hex_value + canonical = dtype.from_json_scalar("NaN", zarr_format=3) + assert dtype.to_json_scalar(canonical, zarr_format=3) == "NaN" + + +def test_noncanonical_nan_serializes_as_hex_complex() -> None: + """The complex path applies the same canonical-NaN check to each part.""" + from zarr.core.dtype.npy.complex import Complex64 + + dtype = Complex64() + real_nan = np.array([0xFFC00000], dtype=np.uint32).view(np.float32)[0] + scalar = np.complex64(complex(real_nan, 0)) + assert dtype.to_json_scalar(scalar, zarr_format=3) == ("0xffc00000", 0.0) + canonical = np.complex64(complex(np.nan, np.nan)) + assert dtype.to_json_scalar(canonical, zarr_format=3) == ("NaN", "NaN") From d23827807c9aaf141f65bea27c5fee6c42c6c47c Mon Sep 17 00:00:00 2001 From: Angus Date: Fri, 9 Oct 2026 13:24:19 -0400 Subject: [PATCH 6/6] derive platform-dependent NaN expectation from fill bits; ruff format --- tests/test_array.py | 13 +++++++++++-- tests/test_dtype/test_npy/test_float.py | 4 +--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_array.py b/tests/test_array.py index f78b895bf9..b90aa6bdc8 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -1037,17 +1037,26 @@ def test_write_empty_chunks_negative_zero( (np.nan * 1j, ["NaN", "NaN"]), (np.nan, ["NaN", 0.0]), (np.inf, ["Infinity", 0.0]), - (np.inf * 1j, ["NaN", "Infinity"]), + (np.inf * 1j, None), (-np.inf, ["-Infinity", 0.0]), (math.inf, ["Infinity", 0.0]), ], ) -async def test_special_complex_fill_values_roundtrip(fill_value: Any, expected: list[Any]) -> None: +async def test_special_complex_fill_values_roundtrip( + fill_value: Any, expected: list[Any] | None +) -> None: store = MemoryStore() zarr.create_array(store=store, shape=(1,), dtype=np.complex64, fill_value=fill_value) content = await store.get("zarr.json", prototype=default_buffer_prototype()) assert content is not None actual = json.loads(content.to_bytes()) + if expected is None: + # inf * 1j computes 0 * inf for the real part, which produces a NaN + # whose sign bit is platform-dependent. Only the exact canonical bits + # serialize as "NaN"; any other NaN writes as hex. + real_bits = int(np.array([np.complex64(fill_value)]).view(np.uint32)[0]) + canonical = int(np.asarray(np.nan, dtype=np.float32).view(np.uint32)) + expected = ["NaN" if real_bits == canonical else f"0x{real_bits:08x}", "Infinity"] assert actual["fill_value"] == expected diff --git a/tests/test_dtype/test_npy/test_float.py b/tests/test_dtype/test_npy/test_float.py index c1ec1c81f9..79f4f6212e 100644 --- a/tests/test_dtype/test_npy/test_float.py +++ b/tests/test_dtype/test_npy/test_float.py @@ -239,9 +239,7 @@ def test_string_float_from_json_scalar() -> None: (Float64(), "0xfff8000000000000"), ], ) -def test_noncanonical_nan_serializes_as_hex( - dtype: BaseFloat[Any, Any], hex_value: str -) -> None: +def test_noncanonical_nan_serializes_as_hex(dtype: BaseFloat[Any, Any], hex_value: str) -> None: """Only the exact canonical NaN bit pattern writes back as ``"NaN"``; every other NaN, including the negative-sign form of the canonical payload, writes back as its hex bits. Regression test for