Repository: dask/dask

Issue:
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.


Discussion:

Resolving patch:
diff --git a/dask/array/creation.py b/dask/array/creation.py
--- 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/utils.py b/dask/array/utils.py
--- 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 after version 2021.10.0 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/utils.py b/dask/utils.py
--- a/dask/utils.py
+++ b/dask/utils.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import functools
 import inspect
 import os
@@ -8,7 +10,7 @@
 import uuid
 import warnings
 from _thread import RLock
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator, Mapping
 from contextlib import contextmanager, nullcontext, suppress
 from datetime import datetime, timedelta
 from errno import ENOENT
@@ -16,7 +18,7 @@
 from importlib import import_module
 from numbers import Integral, Number
 from threading import Lock
-from typing import Dict, Iterable, Mapping, Optional, Type, TypeVar
+from typing import TypeVar
 from weakref import WeakValueDictionary
 
 from .core import get_deps
@@ -39,19 +41,23 @@ def apply(func, args, kwargs=None):
 
 def _deprecated(
     *,
-    version: str = None,
-    message: str = None,
-    use_instead: str = None,
-    category: Type[Warning] = FutureWarning,
+    version: str | None = None,
+    after_version: str | None = None,
+    message: str | None = None,
+    use_instead: str | None = None,
+    category: type[Warning] = FutureWarning,
 ):
     """Decorator to mark a function as deprecated
 
     Parameters
     ----------
     version : str, optional
-        Version of Dask in which the function was deprecated.
-        If specified, the version will be included in the default
-        warning message.
+        Version of Dask in which the function was deprecated. If specified, the version
+        will be included in the default warning message. This should no longer be used
+        after the introduction of automated versioning system.
+    after_version : str, optional
+        Version of Dask after which the function was deprecated. If specified, the
+        version will be included in the default warning message.
     message : str, optional
         Custom warning message to raise.
     use_instead : str, optional
@@ -65,7 +71,7 @@ def _deprecated(
     --------
 
     >>> from dask.utils import _deprecated
-    >>> @_deprecated(version="X.Y.Z", use_instead="bar")
+    >>> @_deprecated(after_version="X.Y.Z", use_instead="bar")
     ... def foo():
     ...     return "baz"
     """
@@ -73,7 +79,9 @@ def _deprecated(
     def decorator(func):
         if message is None:
             msg = f"{func.__name__} "
-            if version is not None:
+            if after_version is not None:
+                msg += f"was deprecated after version {after_version} "
+            elif version is not None:
                 msg += f"was deprecated in version {version} "
             else:
                 msg += "is deprecated "
@@ -900,8 +908,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 +917,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 +1002,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:
+    def __new__(cls, method: str):
+        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,))
@@ -1024,12 +1030,7 @@ def __str__(self):
 
 
 class itemgetter:
-    """
-    Return a callable object that gets an item from the operand
-
-    Unlike the builtin `operator.itemgetter`, instances of this class are
-    serializable
-    """
+    """Variant of operator.itemgetter that supports equality tests"""
 
     __slots__ = ("index",)
 
@@ -1142,7 +1143,7 @@ def get_scheduler_lock(collection=None, scheduler=None):
     return SerializableLock()
 
 
-def ensure_dict(d: Mapping[K, V], *, copy: bool = False) -> Dict[K, V]:
+def ensure_dict(d: Mapping[K, V], *, copy: bool = False) -> dict[K, V]:
     """Convert a generic Mapping into a dict.
     Optimize use case of :class:`~dask.highlevelgraph.HighLevelGraph`.
 
@@ -1704,7 +1705,7 @@ def key_split(s):
         return "Other"
 
 
-def stringify(obj, exclusive: Optional[Iterable] = None):
+def stringify(obj, exclusive: Iterable | None = None):
     """Convert an object to a string
 
     If ``exclusive`` is specified, search through `obj` and convert


Test patch:
diff --git a/dask/array/tests/test_creation.py b/dask/array/tests/test_creation.py
--- 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
--- 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
--- 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/dataframe/tests/test_groupby.py b/dask/dataframe/tests/test_groupby.py
--- 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
@@ -977,7 +977,7 @@ def test_aggregate_build_agg_args__reuse_of_intermediates():
     assert len(with_mean_finalizers) == len(with_mean_spec)
 
 
-def test_aggregate__dask():
+def test_aggregate_dask():
     dask_holder = collections.namedtuple("dask_holder", ["dask"])
     get_agg_dask = lambda obj: dask_holder(
         {k: v for (k, v) in obj.dask.items() if k[0].startswith("aggregate")}
@@ -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
--- a/dask/tests/test_utils.py
+++ b/dask/tests/test_utils.py
@@ -475,6 +475,10 @@ def test_itemgetter():
     assert g2(data) == 2
     assert g2.index == 1
 
+    assert itemgetter(1) == itemgetter(1)
+    assert itemgetter(1) != itemgetter(2)
+    assert itemgetter(1) != 123
+
 
 def test_partial_by_order():
     assert partial_by_order(5, function=operator.add, other=[(1, 20)]) == 25
@@ -720,6 +724,15 @@ def foo():
         assert foo() == "bar"
 
 
+def test_deprecated_after_version():
+    @_deprecated(after_version="1.2.3")
+    def foo():
+        return "bar"
+
+    with pytest.warns(FutureWarning, match="deprecated after version 1.2.3"):
+        assert foo() == "bar"
+
+
 def test_deprecated_category():
     @_deprecated(category=DeprecationWarning)
     def foo():