From c1051807d057439c65e0ace0d2cffc13c35d42d9 Mon Sep 17 00:00:00 2001 From: XXXXXQ-0206 Date: Sat, 17 Oct 2026 01:18:19 +0800 Subject: [PATCH 1/3] fix(indexing): treat an empty list as an empty integer selection --- src/zarr/core/indexing.py | 15 ++++++++++---- tests/test_indexing.py | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/zarr/core/indexing.py b/src/zarr/core/indexing.py index 8ea59eb960..2926551a5b 100644 --- a/src/zarr/core/indexing.py +++ b/src/zarr/core/indexing.py @@ -231,8 +231,8 @@ def is_bool(x: Any) -> TypeGuard[bool | np.bool_]: def is_integer_list(x: Any) -> TypeGuard[list[int]]: - """True if x is a list of integers.""" - return isinstance(x, list) and len(x) > 0 and all(is_integer(i) for i in x) + """True if x is a list of integers (including an empty list, as in NumPy).""" + return isinstance(x, list) and all(is_integer(i) for i in x) def is_bool_list(x: Any) -> TypeGuard[list[bool | np.bool_]]: @@ -510,9 +510,16 @@ def replace_ellipsis(selection: Any, shape: tuple[int, ...]) -> SelectionNormali def replace_lists(selection: SelectionNormalized) -> SelectionNormalized: + def _as_array(dim_sel: list[Any]) -> Any: + # An empty list carries no dtype to infer from. NumPy treats it as an + # empty integer index selection, so default to an integer array. + return cast( + "ArrayOfIntOrBool", + np.asarray(dim_sel, dtype=np.intp) if not dim_sel else np.asarray(dim_sel), + ) + return tuple( - cast("ArrayOfIntOrBool", np.asarray(dim_sel)) if isinstance(dim_sel, list) else dim_sel - for dim_sel in selection + _as_array(dim_sel) if isinstance(dim_sel, list) else dim_sel for dim_sel in selection ) diff --git a/tests/test_indexing.py b/tests/test_indexing.py index 785f089513..752c3250d3 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -428,6 +428,47 @@ def test_get_basic_selection_2d_rejects_list_in_tuple(store: StorePath) -> None: z.get_basic_selection((slice(None), [0, 1])) +@pytest.mark.parametrize("zarr_format", [2, 3]) +def test_empty_list_selection_matches_empty_array_and_numpy(zarr_format: ZarrFormat) -> None: + """An empty list is an empty integer selection, as it is in NumPy. + + IntSequence, the declared selection type, includes list[int], so an empty list + has to behave like the equivalent empty integer array instead of being rejected + by basic indexing. + """ + expected = np.arange(16, dtype="i4").reshape(4, 4) + arr = zarr.create_array({}, data=expected, chunks=(2, 2), zarr_format=zarr_format) + empty = np.array([], dtype=np.intp) + + # Short form, orthogonal form and vectorized form all agree with NumPy. + assert_array_equal(arr[[]], expected[[]]) + assert_array_equal(arr[[], :], expected[[], :]) + assert_array_equal(arr[:, []], expected[:, []]) + assert_array_equal(arr[[], []], expected[[], []]) + assert_array_equal(arr[[], 0], expected[[], 0]) + assert_array_equal(arr[0, []], expected[0, []]) + assert_array_equal(arr.oindex[[]], expected[[]]) + assert_array_equal(arr.oindex[[], :], expected[[], :]) + # The equivalent empty array selection was already accepted. + assert_array_equal(arr[empty], expected[empty]) + assert_array_equal(arr[empty, :], expected[empty, :]) + + # A bare list on a 1D array routes through vectorized indexing. + expected_1d = np.arange(4, dtype="i4") + arr_1d = zarr.create_array({}, data=expected_1d, chunks=(2,), zarr_format=zarr_format) + assert_array_equal(arr_1d[[]], expected_1d[[]]) + assert_array_equal(arr_1d.vindex[[]], expected_1d[[]]) + + # Non-empty integer lists and boolean lists are unaffected. + assert_array_equal(arr[[0]], expected[[0]]) + assert_array_equal(arr[[True, False, True, False]], expected[[True, False, True, False]]) + + # Writing through an empty selection is a no-op. + arr[[], :] = 1 + arr[:, []] = 1 + assert_array_equal(arr[:], expected) + + def test_fancy_indexing_fallback_on_get_setitem(store: StorePath) -> None: """Paired integer-list indexing falls back to vectorized (fancy) get and set via `__getitem__`/`__setitem__`.""" z = zarr_array_from_numpy_array(store, np.zeros((20, 20))) From d018fdd7723107010b30b5660dcd629be7ff5857 Mon Sep 17 00:00:00 2001 From: XXXXXQ-0206 Date: Sat, 17 Oct 2026 02:52:18 +0800 Subject: [PATCH 2/3] chore(changelog): add news fragment for #4518 --- changes/4518.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4518.bugfix.md diff --git a/changes/4518.bugfix.md b/changes/4518.bugfix.md new file mode 100644 index 0000000000..6c227bf393 --- /dev/null +++ b/changes/4518.bugfix.md @@ -0,0 +1 @@ +`arr[[]]`, and the same empty-list selection inside a tuple, an `oindex` selection or a `vindex` selection, used to raise `IndexError: unsupported selection item for basic indexing`, even though `IntSequence`, the declared selection type, includes `list[int]` and the same selection written as an empty integer array already worked. An empty list is now treated as an empty integer selection, matching NumPy, so `arr[[]]` returns an empty array instead of raising. From 8b3d44e15d7efdd820fe849ee204e74e7ecb6848 Mon Sep 17 00:00:00 2001 From: XXXXXQ-0206 Date: Sat, 17 Oct 2026 18:44:33 +0800 Subject: [PATCH 3/3] refactor(indexing): inline the empty-list cast in replace_lists --- src/zarr/core/indexing.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/zarr/core/indexing.py b/src/zarr/core/indexing.py index 2926551a5b..4cb43873d9 100644 --- a/src/zarr/core/indexing.py +++ b/src/zarr/core/indexing.py @@ -510,16 +510,12 @@ def replace_ellipsis(selection: Any, shape: tuple[int, ...]) -> SelectionNormali def replace_lists(selection: SelectionNormalized) -> SelectionNormalized: - def _as_array(dim_sel: list[Any]) -> Any: - # An empty list carries no dtype to infer from. NumPy treats it as an - # empty integer index selection, so default to an integer array. - return cast( - "ArrayOfIntOrBool", - np.asarray(dim_sel, dtype=np.intp) if not dim_sel else np.asarray(dim_sel), - ) - + # An empty list has no dtype to infer; NumPy indexes with it as an empty integer array. return tuple( - _as_array(dim_sel) if isinstance(dim_sel, list) else dim_sel for dim_sel in selection + cast("ArrayOfIntOrBool", np.asarray(dim_sel, dtype=np.intp if len(dim_sel) == 0 else None)) + if isinstance(dim_sel, list) + else dim_sel + for dim_sel in selection )