Repository: dask/dask
Pull request: Make slice errors match numpy
- [ ] Partially fixes #8229
- [ ] Tests added / passed
- [ ] Passes `black dask` / `flake8 dask` / `isort dask`


Problem statement:
Unexpected behaviour with out-of-bound indices
**What happened**:

I encountered the following issue when slicing a Dask array with a one-dimensional Dask array of integers. If the array used for slicing contains indices that are out of range in addition to some valid indices, I would expect an `IndexError` to be raised. Instead, no error is raised and it looks like the element corresponding to the first valid index is repeated for all the out-of-bounds indices. If all indices are out of bounds, then `IndexError` is raised as expected, but the error message includes wrong references to the bad indices and array size. 

**Minimal Complete Verifiable Example**:

```python
In [1]: import dask.array as da

In [2]: x = da.arange(10)

In [3]: idx = 11
   ...: y = x[idx]  # This raises a IndexError, as expected.

In [4]: idx = da.from_array([11])
   ...: y = x[idx]
   ...: y.compute() # This raises a IndexError, but the error message should probably be: "index 11 is out of bounds for axis 0 with size 10"?

In [5]: idx = da.from_array([0, 11])
   ...: y = x[idx]
   ...: y.compute()  # this should raise a `IndexError`. Instead, the zeroth element is repeated.
Out[5]: array([0, 0])  

In [6]: idx = da.from_array([0, 1, 11, 12, 13])
   ...: y = x[idx]
   ...: y.compute()  # same as above - the zeroth element is repeated for all the out-of-bounds indices
Out[6]: array([0, 1, 0, 0, 0])
```

<details><summary>Traceback at [3]</summary>
<p>

```python
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-3-0094457f9d34> in <module>
      1 idx = 11
----> 2 y = x[idx]

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/array/core.py in __getitem__(self, index)
   1737         )
   1738 
-> 1739         index2 = normalize_index(index, self.shape)
   1740         dependencies = {self.name}
   1741         for i in index2:

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/array/slicing.py in normalize_index(idx, shape)
    915     for i, d in zip(idx, none_shape):
    916         if d is not None:
--> 917             check_index(i, d)
    918     idx = tuple(map(sanitize_index, idx))
    919     idx = tuple(map(normalize_slice, idx, none_shape))

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/array/slicing.py in check_index(ind, dimension)
    985 
    986     elif ind >= dimension:
--> 987         raise IndexError(
    988             "Index is not smaller than dimension %d >= %d" % (ind, dimension)
    989         )

IndexError: Index is not smaller than dimension 11 >= 10
```

</p>
</details>

<details><summary>Traceback at [4]</summary>
<p>

```python
--------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-4-795172e62a76> in <module>
      1 idx = da.from_array([11])
      2 y = x[idx]
----> 3 y.compute()

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/base.py in compute(self, **kwargs)
    286         dask.base.compute
    287         """
--> 288         (result,) = compute(self, traverse=False, **kwargs)
    289         return result
    290 

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/base.py in compute(*args, **kwargs)
    568         postcomputes.append(x.__dask_postcompute__())
    569 
--> 570     results = schedule(dsk, keys, **kwargs)
    571     return repack([f(r, *a) for r, (f, a) in zip(results, postcomputes)])
    572 

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/threaded.py in get(dsk, result, cache, num_workers, pool, **kwargs)
     77             pool = MultiprocessingPoolExecutor(pool)
     78 
---> 79     results = get_async(
     80         pool.submit,
     81         pool._max_workers,

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/local.py in get_async(submit, num_workers, dsk, result, cache, get_id, rerun_exceptions_locally, pack_exception, raise_exception, callbacks, dumps, loads, chunksize, **kwargs)
    515                             _execute_task(task, data)  # Re-execute locally
    516                         else:
--> 517                             raise_exception(exc, tb)
    518                     res, worker_id = loads(res_info)
    519                     state["cache"][key] = res

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/local.py in reraise(exc, tb)
    323     if exc.__traceback__ is not tb:
    324         raise exc.with_traceback(tb)
--> 325     raise exc
    326 
    327 

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/local.py in execute_task(key, task_info, dumps, loads, get_id, pack_exception)
    221     try:
    222         task, data = loads(task_info)
--> 223         result = _execute_task(task, data)
    224         id = get_id()
    225         result = dumps((result, id))

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/core.py in _execute_task(arg, cache, dsk)
    119         # temporaries by their reference count and can execute certain
    120         # operations in-place.
--> 121         return func(*(_execute_task(a, cache) for a in args))
    122     elif not ishashable(arg):
    123         return arg

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/optimization.py in __call__(self, *args)
    967         if not len(args) == len(self.inkeys):
    968             raise ValueError("Expected %d args, got %d" % (len(self.inkeys), len(args)))
--> 969         return core.get(self.dsk, self.outkey, dict(zip(self.inkeys, args)))
    970 
    971     def __reduce__(self):

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/core.py in get(dsk, out, cache)
    149     for key in toposort(dsk):
    150         task = dsk[key]
--> 151         result = _execute_task(task, cache)
    152         cache[key] = result
    153     result = _execute_task(out, cache)

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/core.py in _execute_task(arg, cache, dsk)
    119         # temporaries by their reference count and can execute certain
    120         # operations in-place.
--> 121         return func(*(_execute_task(a, cache) for a in args))
    122     elif not ishashable(arg):
    123         return arg

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/utils.py in apply(func, args, kwargs)
     33 def apply(func, args, kwargs=None):
     34     if kwargs:
---> 35         return func(*args, **kwargs)
     36     else:
     37         return func(*args)

/opt/miniconda3/envs/dark_generators/lib/python3.8/site-packages/dask/array/chunk.py in slice_with_int_dask_array_aggregate(idx, chunk_outputs, x_chunks, axis)
    392     # np.take does not support slice indices
    393     # return np.take(chunk_outputs, idx_final, axis)
--> 394     return chunk_outputs[
    395         tuple(
    396             idx_final if i == axis else slice(None) for i in range(chunk_outputs.ndim)

IndexError: index 0 is out of bounds for axis 0 with size 0
```

</p>
</details>

**Environment**:

- Dask version: 2021.9.1
- Python version: 3.8.12
- Operating System: OS X
- Install method (conda, pip, source): `conda install dask[complete] -c conda-forge`

Hints from issue discussion:
Thank you for opening this issue. I agree with your assessment that this behavior is unexpected and can be cleaned up. Are you interested in opening a pull request?
I ended up taking a look and changing the error messages is relatively straightforward, but correcting the results is more involved. I think this might have to do with some of the known issues with blockwise. But I need to do more investigation.

Commit message: Make slice errors match numpy

Patch to review:
diff --git a/dask/array/slicing.py b/dask/array/slicing.py
index 59f17489e54..f2d8c70ed85 100644
--- a/dask/array/slicing.py
+++ b/dask/array/slicing.py
@@ -912,54 +912,54 @@ def normalize_index(idx, shape):
         else:
             none_shape.append(None)
 
-    for i, d in zip(idx, none_shape):
+    for axis, (i, d) in enumerate(zip(idx, none_shape)):
         if d is not None:
-            check_index(i, d)
+            check_index(axis, i, d)
     idx = tuple(map(sanitize_index, idx))
     idx = tuple(map(normalize_slice, idx, none_shape))
     idx = posify_index(none_shape, idx)
     return idx
 
 
-def check_index(ind, dimension):
+def check_index(axis, ind, dimension):
     """Check validity of index for a given dimension
 
     Examples
     --------
-    >>> check_index(3, 5)
-    >>> check_index(5, 5)
+    >>> check_index(0, 3, 5)
+    >>> check_index(0, 5, 5)
     Traceback (most recent call last):
     ...
-    IndexError: Index is not smaller than dimension 5 >= 5
+    IndexError: Index 5 is out of bounds for axis 0 with size 5
 
-    >>> check_index(6, 5)
+    >>> check_index(1, 6, 5)
     Traceback (most recent call last):
     ...
-    IndexError: Index is not smaller than dimension 6 >= 5
+    IndexError: Index 6 is out of bounds for axis 1 with size 5
 
-    >>> check_index(-1, 5)
-    >>> check_index(-6, 5)
+    >>> check_index(1, -1, 5)
+    >>> check_index(1, -6, 5)
     Traceback (most recent call last):
     ...
-    IndexError: Negative index is not greater than negative dimension -6 <= -5
+    IndexError: Index -6 is out of bounds for axis 1 with size 5
 
-    >>> check_index([1, 2], 5)
-    >>> check_index([6, 3], 5)
+    >>> check_index(0, [1, 2], 5)
+    >>> check_index(0, [6, 3], 5)
     Traceback (most recent call last):
     ...
-    IndexError: Index out of bounds 5
+    IndexError: Index is out of bounds for axis 0 with size 5
 
-    >>> check_index(slice(0, 3), 5)
+    >>> check_index(1, slice(0, 3), 5)
 
-    >>> check_index([True], 1)
-    >>> check_index([True, True], 3)
+    >>> check_index(0, [True], 1)
+    >>> check_index(0, [True, True], 3)
     Traceback (most recent call last):
     ...
-    IndexError: Boolean array length 2 doesn't equal dimension 3
-    >>> check_index([True, True, True], 1)
+    IndexError: Boolean array with size 2 is not long enough for axis 0 with size 3
+    >>> check_index(0, [True, True, True], 1)
     Traceback (most recent call last):
     ...
-    IndexError: Boolean array length 3 doesn't equal dimension 1
+    IndexError: Boolean array with size 3 is not long enough for axis 0 with size 1
     """
     if isinstance(ind, list):
         ind = np.asanyarray(ind)
@@ -973,25 +973,23 @@ def check_index(ind, dimension):
         if ind.dtype == bool:
             if ind.size != dimension:
                 raise IndexError(
-                    "Boolean array length %s doesn't equal dimension %s"
-                    % (ind.size, dimension)
+                    f"Boolean array with size {ind.size} is not long enough"
+                    f"for axis {axis} with size {dimension}"
                 )
         elif (ind >= dimension).any() or (ind < -dimension).any():
-            raise IndexError("Index out of bounds %s" % dimension)
+            raise IndexError(
+                f"Index is out of bounds for axis {axis} with size {dimension}"
+            )
     elif isinstance(ind, slice):
         return
     elif ind is None:
         return
 
-    elif ind >= dimension:
+    elif ind >= dimension or ind < -dimension:
         raise IndexError(
-            "Index is not smaller than dimension %d >= %d" % (ind, dimension)
+            f"Index {ind} is out of bounds for axis {axis} with size {dimension}"
         )
 
-    elif ind < -dimension:
-        msg = "Negative index is not greater than negative dimension %d <= -%d"
-        raise IndexError(msg % (ind, dimension))
-
 
 def slice_with_int_dask_array(x, index):
     """Slice x with at most one 1D dask arrays of ints.


Code review comments:
diff_hunk: @@ -973,25 +973,23 @@ def check_index(ind, dimension):
         if ind.dtype == bool:
             if ind.size != dimension:
                 raise IndexError(
-                    "Boolean array length %s doesn't equal dimension %s"
-                    % (ind.size, dimension)
+                    f"Boolean array with size {ind.size} is not long enough"
path: dask/array/slicing.py
text: @user1:
Missing space is causing a test failure (yay tests!)

```suggestion
                    f"Boolean array with size {ind.size} is not long enough "
```

@author:
:)