diff --git a/changes/4257.bugfix.md b/changes/4257.bugfix.md new file mode 100644 index 0000000000..6f2740ccb4 --- /dev/null +++ b/changes/4257.bugfix.md @@ -0,0 +1 @@ +Numpy integers are accepted as chunk sizes again. Since 3.3.0 a per-dimension chunk size that was a numpy integer (e.g. `chunks=(np.int64(2), np.int64(2))`, as produced by any computed chunk shape) raised `TypeError: 'numpy.int64' object is not iterable`, because the scalar chunk path narrowed on `int` while its caller dispatched on `numbers.Integral`. Numpy arrays are now also accepted as chunk specifications, and a chunk specification that is neither an integer nor iterable now reports the offending value instead of failing with an opaque iteration error. diff --git a/src/zarr/core/chunk_grids.py b/src/zarr/core/chunk_grids.py index 2cb9762775..584829bc6c 100644 --- a/src/zarr/core/chunk_grids.py +++ b/src/zarr/core/chunk_grids.py @@ -729,17 +729,26 @@ def normalize_chunks_1d( overhang the span. The actual data extent of each chunk is determined by the chunk grid at runtime, not by this function. """ - if chunks == -1: - return np.array([span], dtype=np.int64) - if isinstance(chunks, int): - if chunks <= 0: - raise ValueError(f"Chunk size must be positive, got {chunks}") + # `numbers.Integral` rather than `int` so that numpy integer scalars (which are not + # `int` subclasses) take the uniform-chunk path instead of being treated as a sequence. + if isinstance(chunks, numbers.Integral): + chunk_size = int(chunks) + if chunk_size == -1: + return np.array([span], dtype=np.int64) + if chunk_size <= 0: + raise ValueError(f"Chunk size must be positive, got {chunk_size}") if span == 0: - return np.array([chunks], dtype=np.int64) - n = ceildiv(span, chunks) - return np.full(n, chunks, dtype=np.int64) + return np.array([chunk_size], dtype=np.int64) + n = ceildiv(span, chunk_size) + return np.full(n, chunk_size, dtype=np.int64) else: - chunk_list = list(chunks) + try: + chunk_list = list(chunks) # type: ignore[arg-type] + except TypeError: + raise TypeError( + f"Chunk specification must be an integer or an iterable of integers; got " + f"{chunks!r} of type {type(chunks).__name__}." + ) from None if not chunk_list: raise ValueError("Chunk specification must not be empty") non_int = [ diff --git a/tests/test_api.py b/tests/test_api.py index 2b831e942d..45d0c0dee4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -79,7 +79,7 @@ def test_create(memory_store: Store) -> None: z = create(shape=(400.5, 100), store=store, overwrite=True) # type: ignore[arg-type] # create array with float chunk shape - with pytest.raises(TypeError, match="'float' object is not iterable"): + with pytest.raises(TypeError, match="Chunk specification must be an integer or an iterable"): z = create(shape=(400, 100), chunks=(16, 16.5), store=store, overwrite=True) # type: ignore[arg-type] diff --git a/tests/test_chunk_grids.py b/tests/test_chunk_grids.py index b730a43901..4640c43d1c 100644 --- a/tests/test_chunk_grids.py +++ b/tests/test_chunk_grids.py @@ -68,6 +68,15 @@ def test_guess_chunks(shape: tuple[int, ...], itemsize: int) -> None: (10, (0,), ((10,),)), ((5, 10), (0, 100), ((5,), (10,) * 10)), ((5, 10), (20, 0), ((5, 5, 5, 5), (10,))), + # numpy integers are accepted anywhere a python int is, whether as the scalar + # convenience form, as per-dimension entries, or as the `-1` sentinel. + (np.int64(10), (100,), ((10,) * 10,)), + ((np.int64(2), np.int64(2)), (4, 4), ((2, 2), (2, 2))), + ((1, 3, np.int64(16), np.int64(16)), (1, 3, 32, 32), ((1,), (3,), (16, 16), (16, 16))), + ((np.int32(30), np.int64(-1)), (100, 20), ((30, 30, 30, 30), (20,))), + (np.array([10, 10]), (100, 100), ((10,) * 10, (10,) * 10)), + # rectilinear chunks given as numpy arrays + ((np.array([60, 40]), np.array([50, 50])), (100, 100), ((60, 40), (50, 50))), ], ) def test_normalize_chunks( @@ -142,7 +151,22 @@ def test_chunk_layout_nested() -> None: id="negative-uniform", msg="Chunk size must be positive", ), + ExpectFail( + input=(np.int64(0), 100), + exception=ValueError, + id="zero-uniform-numpy", + msg="Chunk size must be positive", + ), ExpectFail(input=([], 100), exception=ValueError, id="empty-list", msg="must not be empty"), + # Scalars that are neither integers nor iterable name themselves in the error, + # rather than surfacing an opaque "object is not iterable" from `list(chunks)`. + ExpectFail( + input=(2.5, 100), + exception=TypeError, + id="non-iterable-scalar", + msg="must be an integer or an iterable of integers; got 2.5 of type float", + escape=True, + ), ExpectFail( input=([10, -1, 10], 100), exception=ValueError,