Repository: dask/dask
Pull request: Deprecate `AxisError`
Closes #8301

Problem statement:
Deprecate AxisError, itemgetter, methodcaller
These classes and functions no longer have a reason to exist:

- dask.array.utils.AxisError (backwards compatibility around numpy.AxisError - no longer needed with the current minimum numpy dependency)
- dask.utils.itemgetter (serializable variant of operator.itemgetter - which can be serialized as of Python 3.7)
- dask.utils.methodcaller (serializable variant of operator.methodcaller - which can be serialized as of Python 3.7)

They are undocumented and as such they should be treated as internals. However it has been agreed at the maintainers meeting that we should go through a deprecation cycle for peace of mind.

Hints from issue discussion:

Commit message: Deprecate AxisError and itemgetter

Patch to review:
diff --git a/dask/array/creation.py b/dask/array/creation.py
index 81c02af3969..e953b03f02f 100644
--- a/dask/array/creation.py
+++ b/dask/array/creation.py
@@ -26,7 +26,7 @@
 )
 from .numpy_compat import _numpy_120
 from .ufunc import greater_equal, rint
-from .utils import AxisError, meta_from_array
+from .utils import meta_from_array
 from .wrap import empty, full, ones, zeros
 
 
@@ -626,7 +626,7 @@ def _axis_fmt(axis, name, ndim):
             t = ndim + axis
             if t < 0:
                 msg = "{}: axis {} is out of bounds for array of dimension {}"
-                raise AxisError(msg.format(name, axis, ndim))
+                raise np.AxisError(msg.format(name, axis, ndim))
             axis = t
         return axis
 
diff --git a/dask/array/tests/test_creation.py b/dask/array/tests/test_creation.py
index aa50aa15bf0..37e05997744 100644
--- a/dask/array/tests/test_creation.py
+++ b/dask/array/tests/test_creation.py
@@ -9,7 +9,7 @@
 import dask
 import dask.array as da
 from dask.array.core import normalize_chunks
-from dask.array.utils import AxisError, assert_eq, same_keys
+from dask.array.utils import assert_eq, same_keys
 
 
 @pytest.mark.parametrize(
@@ -444,10 +444,10 @@ def test_diagonal():
     with pytest.raises(ValueError):
         da.diagonal(v, axis1=0, axis2=0)
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.diagonal(v, axis1=-4)
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.diagonal(v, axis2=-4)
 
     v = np.arange(4 * 5 * 6).reshape((4, 5, 6))
diff --git a/dask/array/tests/test_cupy_creation.py b/dask/array/tests/test_cupy_creation.py
index 5d6cfd4b5e4..0b4f506345e 100644
--- a/dask/array/tests/test_cupy_creation.py
+++ b/dask/array/tests/test_cupy_creation.py
@@ -5,7 +5,7 @@
 
 import dask.array as da
 from dask.array.numpy_compat import _numpy_120
-from dask.array.utils import AxisError, assert_eq
+from dask.array.utils import assert_eq
 
 cupy = pytest.importorskip("cupy")
 
@@ -41,10 +41,10 @@ def test_diagonal():
     with pytest.raises(ValueError):
         da.diagonal(v, axis1=0, axis2=0)
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.diagonal(v, axis1=-4)
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.diagonal(v, axis2=-4)
 
     v = cupy.arange(4 * 5 * 6).reshape((4, 5, 6))
diff --git a/dask/array/tests/test_routines.py b/dask/array/tests/test_routines.py
index c668ad2e9c7..3f9e3782dd3 100644
--- a/dask/array/tests/test_routines.py
+++ b/dask/array/tests/test_routines.py
@@ -10,7 +10,7 @@
 np = pytest.importorskip("numpy")
 
 import dask.array as da
-from dask.array.utils import AxisError, assert_eq, same_keys
+from dask.array.utils import assert_eq, same_keys
 
 
 def test_array():
@@ -2170,10 +2170,10 @@ def test_insert():
     with pytest.raises(NotImplementedError):
         da.insert(a, [4, 2], -1, axis=0)
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.insert(a, [3], -1, axis=2)
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.insert(a, [3], -1, axis=-3)
 
 
@@ -2215,9 +2215,9 @@ def test_append():
     )
 
     # check AxisError
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.append(a, ((0,) * 10,) * 10, axis=2)
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.append(a, ((0,) * 10,) * 10, axis=-3)
 
     # check ValueError if dimensions don't align
@@ -2254,10 +2254,10 @@ def test_delete():
 
     assert_eq(np.delete(a, [4, 2], axis=0), da.delete(a, [4, 2], axis=0))
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.delete(a, [3], axis=2)
 
-    with pytest.raises(AxisError):
+    with pytest.raises(np.AxisError):
         da.delete(a, [3], axis=-3)
 
 
diff --git a/dask/array/utils.py b/dask/array/utils.py
index 2c7b523bf09..a7e19d5ad1e 100644
--- a/dask/array/utils.py
+++ b/dask/array/utils.py
@@ -12,14 +12,6 @@
 from ..utils import has_keyword, is_arraylike, is_cupy_type
 from .core import Array
 
-try:
-    AxisError = np.AxisError
-except AttributeError:
-    try:
-        np.array([0]).sum(axis=5)
-    except Exception as e:
-        AxisError = type(e)
-
 
 def normalize_to_array(x):
     if is_cupy_type(x):
@@ -462,7 +454,7 @@ def validate_axis(axis, ndim):
     if not isinstance(axis, numbers.Integral):
         raise TypeError("Axis value must be an integer, got %s" % axis)
     if axis < -ndim or axis >= ndim:
-        raise AxisError(
+        raise np.AxisError(
             "Axis %d is out of bounds for array of dimension %d" % (axis, ndim)
         )
     if axis < 0:
@@ -530,3 +522,17 @@ def scipy_linalg_safe(func_name, *args, **kwargs):
 
 def solve_triangular_safe(a, b, lower=False):
     return scipy_linalg_safe("solve_triangular", a, b, lower=lower)
+
+
+def __getattr__(name):
+    # Can't use the @_deprecated decorator as it would not work on `except AxisError`
+    if name == "AxisError":
+        warnings.warn(
+            "AxisError was deprecated in version 2021.11 and will be removed in a "
+            "future release. Please use numpy.AxisError instead.",
+            category=FutureWarning,
+            stacklevel=2,
+        )
+        return np.AxisError
+    else:
+        raise AttributeError(f"module {__name__} has no attribute {name}")
diff --git a/dask/dataframe/groupby.py b/dask/dataframe/groupby.py
index d9dd451af9a..2e7b6e3ee46 100644
--- a/dask/dataframe/groupby.py
+++ b/dask/dataframe/groupby.py
@@ -8,7 +8,7 @@
 
 from ..base import tokenize
 from ..highlevelgraph import HighLevelGraph
-from ..utils import M, derived_from, funcname, itemgetter
+from ..utils import M, derived_from, funcname
 from .core import (
     DataFrame,
     Series,
@@ -765,7 +765,7 @@ def _build_agg_args_simple(result_column, func, input_column, impl_pair):
                 dict(column=intermediate, func=agg_impl),
             )
         ],
-        finalizer=(result_column, itemgetter(intermediate), dict()),
+        finalizer=(result_column, operator.itemgetter(intermediate), dict()),
     )
 
 
@@ -845,7 +845,7 @@ def _build_agg_args_list(result_column, func, input_column):
                 ),
             )
         ],
-        finalizer=(result_column, itemgetter(intermediate), dict()),
+        finalizer=(result_column, operator.itemgetter(intermediate), dict()),
     )
 
 
diff --git a/dask/dataframe/tests/test_groupby.py b/dask/dataframe/tests/test_groupby.py
index 0aa2d3271e2..70e66268497 100644
--- a/dask/dataframe/tests/test_groupby.py
+++ b/dask/dataframe/tests/test_groupby.py
@@ -1,6 +1,6 @@
 import collections
+import operator
 import warnings
-from operator import methodcaller
 
 import numpy as np
 import pandas as pd
@@ -2438,7 +2438,7 @@ def test_groupby_empty_partitions_with_rows_operation(operation):
         columns=["A", "B"],
     )
 
-    caller = methodcaller(operation, 1)
+    caller = operator.methodcaller(operation, 1)
     expected = caller(df.groupby("A")["B"])
     ddf = dd.from_pandas(df, npartitions=3)
     actual = caller(ddf.groupby("A")["B"])
@@ -2462,7 +2462,7 @@ def test_groupby_with_row_operations(operation):
         columns=["A", "B"],
     )
 
-    caller = methodcaller(operation)
+    caller = operator.methodcaller(operation)
     expected = caller(df.groupby("A")["B"])
     ddf = dd.from_pandas(df, npartitions=3)
     actual = caller(ddf.groupby("A")["B"])
@@ -2486,7 +2486,7 @@ def test_groupby_multi_index_with_row_operations(operation):
         columns=["A", "B"],
     )
 
-    caller = methodcaller(operation)
+    caller = operator.methodcaller(operation)
     expected = caller(df.groupby(["A", df["A"].eq("a1")])["B"])
     ddf = dd.from_pandas(df, npartitions=3)
     actual = caller(ddf.groupby(["A", ddf["A"].eq("a1")])["B"])
diff --git a/dask/tests/test_utils.py b/dask/tests/test_utils.py
index 6e7bf9a5de3..087f808abdd 100644
--- a/dask/tests/test_utils.py
+++ b/dask/tests/test_utils.py
@@ -469,11 +469,10 @@ class mydict(dict):
 
 def test_itemgetter():
     data = [1, 2, 3]
-    g = itemgetter(1)
+    with pytest.warns(FutureWarning):
+        g = itemgetter(1)
+    assert isinstance(g, operator.itemgetter)
     assert g(data) == 2
-    g2 = pickle.loads(pickle.dumps(g))
-    assert g2(data) == 2
-    assert g2.index == 1
 
 
 def test_partial_by_order():
diff --git a/dask/utils.py b/dask/utils.py
index 7404b633dc3..57d3b76bfeb 100644
--- a/dask/utils.py
+++ b/dask/utils.py
@@ -1,5 +1,6 @@
 import functools
 import inspect
+import operator
 import os
 import re
 import shutil
@@ -900,8 +901,6 @@ def ensure_bytes(s):
 def ensure_unicode(s):
     """Turn string or bytes to bytes
 
-    >>> ensure_unicode('123')
-    '123'
     >>> ensure_unicode('123')
     '123'
     >>> ensure_unicode(b'123')
@@ -911,8 +910,7 @@ def ensure_unicode(s):
         return s
     if hasattr(s, "decode"):
         return s.decode()
-    msg = "Object %s is neither a bytes object nor has an encode method"
-    raise TypeError(msg % s)
+    raise TypeError(f"Object {s} is neither a str object nor has an decode method")
 
 
 def digit(n, k, base):
@@ -997,22 +995,23 @@ class methodcaller:
     Return a callable object that calls the given method on its operand.
 
     Unlike the builtin `operator.methodcaller`, instances of this class are
-    serializable
+    cached and arguments are passed at call time instead of build time.
     """
 
     __slots__ = ("method",)
     func = property(lambda self: self.method)  # For `funcname` to work
 
     def __new__(cls, method):
-        if method in _method_cache:
+        try:
             return _method_cache[method]
-        self = object.__new__(cls)
-        self.method = method
-        _method_cache[method] = self
-        return self
+        except KeyError:
+            self = object.__new__(cls)
+            self.method = method
+            _method_cache[method] = self
+            return self
 
-    def __call__(self, obj, *args, **kwargs):
-        return getattr(obj, self.method)(*args, **kwargs)
+    def __call__(self, __obj, *args, **kwargs):
+        return getattr(__obj, self.method)(*args, **kwargs)
 
     def __reduce__(self):
         return (methodcaller, (self.method,))
@@ -1023,27 +1022,9 @@ def __str__(self):
     __repr__ = __str__
 
 
-class itemgetter:
-    """
-    Return a callable object that gets an item from the operand
-
-    Unlike the builtin `operator.itemgetter`, instances of this class are
-    serializable
-    """
-
-    __slots__ = ("index",)
-
-    def __init__(self, index):
-        self.index = index
-
-    def __call__(self, x):
-        return x[self.index]
-
-    def __reduce__(self):
-        return (itemgetter, (self.index,))
-
-    def __eq__(self, other):
-        return type(self) is type(other) and self.index == other.index
+@_deprecated(version="2021.11", use_instead="operator.itemgetter")
+def itemgetter(index):
+    return operator.itemgetter(index)
 
 
 class MethodCache:


Code review comments:
diff_hunk: @@ -530,3 +522,17 @@ def scipy_linalg_safe(func_name, *args, **kwargs):
 
 def solve_triangular_safe(a, b, lower=False):
     return scipy_linalg_safe("solve_triangular", a, b, lower=lower)
+
+
+def __getattr__(name):
+    # Can't use the @_deprecated decorator as it would not work on `except AxisError`
+    if name == "AxisError":
+        warnings.warn(
+            "AxisError was deprecated in version 2021.11 and will be removed in a "
path: dask/array/utils.py
text: @user1:
Since we're using CalVer for our versioning scheme `2021.11` isn't well defined. Maybe it would be more clear to say "deprecated after the 2021.10.0 release" instead? Alternatively, we could also just say `AxisError` is deprecated without specifying a specific version 

@author:
fixed