Skip to content
1 change: 1 addition & 0 deletions changes/4492.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 31 additions & 8 deletions src/zarr/core/dtype/npy/int.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 "
Expand Down Expand Up @@ -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:
"""
Expand Down
59 changes: 59 additions & 0 deletions tests/test_dtype/test_npy/test_int.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Loading