diff --git a/changes/4492.bugfix.md b/changes/4492.bugfix.md new file mode 100644 index 0000000000..83500c2deb --- /dev/null +++ b/changes/4492.bugfix.md @@ -0,0 +1 @@ +Raise a `ValueError` for out-of-bounds integer fill values instead of leaking NumPy's `OverflowError`, both when reading stored metadata and when casting values at array creation. diff --git a/src/zarr/core/dtype/npy/int.py b/src/zarr/core/dtype/npy/int.py index b7198fcb0e..ed79815a5a 100644 --- a/src/zarr/core/dtype/npy/int.py +++ b/src/zarr/core/dtype/npy/int.py @@ -143,6 +143,26 @@ def _cast_scalar_unchecked(self, data: IntLike) -> Scalar: return self.to_native_dtype().type(data) # type: ignore[return-value] + def _check_int_bounds(self, data: object, value: int) -> None: + """ + Check that an integer value fits in this data type's range. + + Parameters + ---------- + data : object + The original input, used in the error message. + value : int + The integer value to bounds-check. + + Raises + ------ + ValueError + If ``value`` is outside the range of the native dtype. + """ + info = np.iinfo(self.to_native_dtype()) + if value < info.min or value > info.max: + raise ValueError(f"{data!r} is out of bounds for {self.to_native_dtype()}.") + def cast_scalar(self, data: object) -> Scalar: """ Attempt to cast a given object to a NumPy integer scalar. @@ -161,9 +181,12 @@ def cast_scalar(self, data: object) -> Scalar: ------ TypeError If the data cannot be converted to a NumPy integer scalar. + ValueError + If the data is an integer outside the range of the dtype. """ if self._check_scalar(data): + self._check_int_bounds(data, int(data)) return self._cast_scalar_unchecked(data) msg = ( f"Cannot convert object {data!r} with type {type(data)} to a scalar compatible with the " @@ -202,16 +225,16 @@ def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> Scalar: ------ TypeError If the input is not a valid integer type. + ValueError + If the input is an integer outside the range of the dtype. """ - if check_json_int(data): - 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)) + if check_json_int(data) or check_json_intish_float(data) or check_json_intish_str(data): + value = int(data) + else: + raise TypeError(f"Invalid type: {data}. Expected an integer.") - raise TypeError(f"Invalid type: {data}. Expected an integer.") + self._check_int_bounds(data, value) + return self._cast_scalar_unchecked(value) def to_json_scalar(self, data: object, *, zarr_format: ZarrFormat) -> int: """ diff --git a/tests/test_dtype/test_npy/test_int.py b/tests/test_dtype/test_npy/test_int.py index f25fa1a564..2b498223ae 100644 --- a/tests/test_dtype/test_npy/test_int.py +++ b/tests/test_dtype/test_npy/test_int.py @@ -1,10 +1,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np +import pytest from tests.test_dtype.test_wrapper import BaseTestZDType from zarr.core.dtype.npy.int import Int8, Int16, Int32, Int64, UInt8, UInt16, UInt32, UInt64 +if TYPE_CHECKING: + from zarr.core.common import JSON + class TestInt8(BaseTestZDType): test_cls = Int8 @@ -337,3 +343,56 @@ def test_string_integer_from_json_scalar() -> None: # Test that it works for v2 format too result = dtype_instance.from_json_scalar("123", zarr_format=2) assert result == np.int32(123) + + +@pytest.mark.parametrize( + ("dtype_instance", "value"), + [ + (Int8(), 300), + (Int8(), -129), + (Int8(), "300"), + (Int8(), 300.0), + (UInt8(), -1), + (UInt64(), 2**64), + (Int64(), -(2**63) - 1), + ], +) +def test_out_of_bounds_integer_from_json_scalar( + dtype_instance: Int8 | UInt8 | UInt64 | Int64, value: JSON +) -> None: + """An integer outside the dtype's range raises a validation error, not + numpy's OverflowError. Regression test for + https://github.com/zarr-developers/zarr-python/issues/4453 item 4.""" + for zarr_format in (2, 3): + with pytest.raises(ValueError, match="out of bounds"): + dtype_instance.from_json_scalar(value, zarr_format=zarr_format) + + +@pytest.mark.parametrize( + ("dtype_instance", "value"), + [ + (Int8(), 127), + (Int8(), -128), + (UInt8(), 255), + (UInt64(), 2**64 - 1), + (Int64(), -(2**63)), + ], +) +def test_in_bounds_integer_from_json_scalar( + dtype_instance: Int8 | UInt8 | UInt64 | Int64, value: int +) -> None: + """In-bounds integers round-trip for both zarr formats.""" + for zarr_format in (2, 3): + assert dtype_instance.from_json_scalar( + value, zarr_format=zarr_format + ) == dtype_instance.to_native_dtype().type(value) + + +def test_out_of_bounds_integer_cast_scalar() -> None: + """cast_scalar raises ValueError for out-of-bounds integers, so + create_array(dtype="i1", fill_value=300) fails the same way reading + stored metadata does.""" + with pytest.raises(ValueError, match="out of bounds"): + Int8().cast_scalar(300) + with pytest.raises(ValueError, match="out of bounds"): + UInt64().cast_scalar(2**64)