diff --git a/src/zarr/core/dtype/npy/common.py b/src/zarr/core/dtype/npy/common.py index ab22b542f0..107b3bd12d 100644 --- a/src/zarr/core/dtype/npy/common.py +++ b/src/zarr/core/dtype/npy/common.py @@ -58,6 +58,12 @@ IntishFloat = NewType("IntishFloat", float) """A type for floats that represent integers, like 1.0 (but not 1.1).""" +IntishStr = NewType("IntishStr", str) +"""A type for strings that represent integers, like "0" or "42".""" + +FloatishStr = NewType("FloatishStr", str) +"""A type for strings that represent floats, like "3.14" or "-2.5".""" + NumpyEndiannessStr = Literal[">", "<", "="] NUMPY_ENDIANNESS_STR: Final = ">", "<", "=" @@ -488,6 +494,59 @@ def check_json_intish_float(data: JSON) -> TypeGuard[IntishFloat]: return isinstance(data, float) and data.is_integer() +def check_json_intish_str(data: JSON) -> TypeGuard[IntishStr]: + """ + Check if a JSON value is a string that represents an integer, like "0", "42", or "-5". + + Parameters + ---------- + data : JSON + The JSON value to check. + + Returns + ------- + bool + True if the data is a string representing an integer, False otherwise. + """ + if not isinstance(data, str): + return False + + try: + int(data) + except ValueError: + return False + else: + return True + + +def check_json_floatish_str(data: JSON) -> TypeGuard[FloatishStr]: + """ + Check if a JSON value is a string that represents a float, like "3.14", "-2.5", or "0.0". + + Note: This function is intended to be used AFTER check_json_float_v2/v3, so it only + handles regular string representations that those functions don't cover. + + Parameters + ---------- + data : JSON + The JSON value to check. + + Returns + ------- + bool + True if the data is a string representing a regular float, False otherwise. + """ + if not isinstance(data, str): + return False + + try: + float(data) + except ValueError: + return False + else: + return True + + def check_json_str(data: JSON) -> TypeGuard[str]: """ Check if a JSON value is a string. diff --git a/src/zarr/core/dtype/npy/float.py b/src/zarr/core/dtype/npy/float.py index bedb44b52d..0be2cbca9b 100644 --- a/src/zarr/core/dtype/npy/float.py +++ b/src/zarr/core/dtype/npy/float.py @@ -19,6 +19,7 @@ TFloatScalar_co, check_json_float_v2, check_json_float_v3, + check_json_floatish_str, endianness_to_numpy_str, float_from_json_v2, float_from_json_v3, @@ -270,6 +271,8 @@ def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> TFloatScal if zarr_format == 2: if check_json_float_v2(data): return self._cast_scalar_unchecked(float_from_json_v2(data)) + elif check_json_floatish_str(data): + return self._cast_scalar_unchecked(float(data)) else: raise TypeError( f"Invalid type: {data}. Expected a float or a special string encoding of a float." @@ -277,6 +280,8 @@ def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> TFloatScal elif zarr_format == 3: if check_json_float_v3(data): return self._cast_scalar_unchecked(float_from_json_v3(data)) + elif check_json_floatish_str(data): + return self._cast_scalar_unchecked(float(data)) else: raise TypeError( f"Invalid type: {data}. Expected a float or a special string encoding of a float." diff --git a/src/zarr/core/dtype/npy/int.py b/src/zarr/core/dtype/npy/int.py index 6f7ebc2f55..580776a865 100644 --- a/src/zarr/core/dtype/npy/int.py +++ b/src/zarr/core/dtype/npy/int.py @@ -26,6 +26,7 @@ from zarr.core.dtype.npy.common import ( check_json_int, check_json_intish_float, + check_json_intish_str, endianness_to_numpy_str, get_endianness_from_numpy_dtype, ) @@ -209,6 +210,10 @@ def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> TIntScalar return self._cast_scalar_unchecked(data) if check_json_intish_float(data): return self._cast_scalar_unchecked(int(data)) + + if check_json_intish_str(data): + return self._cast_scalar_unchecked(int(data)) + raise TypeError(f"Invalid type: {data}. Expected an integer.") def to_json_scalar(self, data: object, *, zarr_format: ZarrFormat) -> int: diff --git a/tests/test_dtype/test_npy/test_float.py b/tests/test_dtype/test_npy/test_float.py index 90fa27c9cf..1bbcbbc81f 100644 --- a/tests/test_dtype/test_npy/test_float.py +++ b/tests/test_dtype/test_npy/test_float.py @@ -167,3 +167,47 @@ class TestFloat64(_BaseTestFloat): ("0x3ff0000000000000", 1.0), ) item_size_params = (Float64(),) + + +def test_check_json_floatish_str() -> None: + """Test the check_json_floatish_str function.""" + from zarr.core.dtype.npy.common import check_json_floatish_str + + # Test valid string floats + assert check_json_floatish_str("3.14") + assert check_json_floatish_str("0.0") + assert check_json_floatish_str("-2.5") + assert check_json_floatish_str("1.0") + + # Test invalid cases + assert not check_json_floatish_str("not_a_number") + assert not check_json_floatish_str("") + assert not check_json_floatish_str(3.14) # actual float, not string + assert not check_json_floatish_str(42) # int + assert not check_json_floatish_str(None) + + # Test that special cases still work via float() conversion + # (these will be handled by existing functions first in practice) + assert check_json_floatish_str("NaN") + assert check_json_floatish_str("Infinity") + assert check_json_floatish_str("-Infinity") + + +def test_string_float_from_json_scalar() -> None: + """Test that string representations of floats can be parsed by from_json_scalar.""" + # Test with Float32 + dtype_instance = Float32() + result = dtype_instance.from_json_scalar("3.14", zarr_format=3) + assert abs(result - np.float32(3.14)) < 1e-6 + assert isinstance(result, np.float32) + + # Test other cases + result = dtype_instance.from_json_scalar("0.0", zarr_format=3) + assert result == np.float32(0.0) + + result = dtype_instance.from_json_scalar("-2.5", zarr_format=3) + assert result == np.float32(-2.5) + + # 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) diff --git a/tests/test_dtype/test_npy/test_int.py b/tests/test_dtype/test_npy/test_int.py index 0cab3b8e3e..f53ec7f5ae 100644 --- a/tests/test_dtype/test_npy/test_int.py +++ b/tests/test_dtype/test_npy/test_int.py @@ -281,3 +281,42 @@ class TestUInt64(BaseTestZDType): ) invalid_scalar_params = ((UInt64(), {"set!"}), (UInt64(), ("tuple",))) item_size_params = (UInt64(),) + + +def test_check_json_intish_str() -> None: + """Test the check_json_intish_str function.""" + from zarr.core.dtype.npy.common import check_json_intish_str + + # Test valid string integers + assert check_json_intish_str("0") + assert check_json_intish_str("42") + assert check_json_intish_str("-5") + assert check_json_intish_str("123") + + # Test invalid cases + assert not check_json_intish_str("3.14") + assert not check_json_intish_str("not_a_number") + assert not check_json_intish_str("") + assert not check_json_intish_str(42) # actual int, not string + assert not check_json_intish_str(3.14) # float + assert not check_json_intish_str(None) + + +def test_string_integer_from_json_scalar() -> None: + """Test that string representations of integers can be parsed by from_json_scalar.""" + # Test the specific reproducer case + dtype_instance = Int32() + result = dtype_instance.from_json_scalar("0", zarr_format=3) + assert result == np.int32(0) + assert isinstance(result, np.int32) + + # Test other cases + result = dtype_instance.from_json_scalar("42", zarr_format=3) + assert result == np.int32(42) + + result = dtype_instance.from_json_scalar("-5", zarr_format=3) + assert result == np.int32(-5) + + # Test that it works for v2 format too + result = dtype_instance.from_json_scalar("123", zarr_format=2) + assert result == np.int32(123)