Repository: cogent3/cogent3
Pull request #1357 (2023-04-13 23:16:51)

DEV: convenience decorator for deprecating callables


Patch:
diff --git a/src/cogent3/util/warning.py b/src/cogent3/util/warning.py
index 12f453c66..73b06c960 100644
--- a/src/cogent3/util/warning.py
+++ b/src/cogent3/util/warning.py
@@ -1,7 +1,7 @@
-#!/usr/bin/env python
 import functools
+import inspect
 
-from typing import Any, Callable, List, Tuple
+from typing import Any, Callable, List, Optional, Tuple
 from warnings import catch_warnings, simplefilter
 from warnings import warn as _warn
 
@@ -34,9 +34,7 @@ def deprecated(_type, old, new, version, reason=None, stack_level=3):
         as per warnings.warn
 
     """
-    msg = (
-        f"use {_type} {new} instead of {old}, support discontinued in version {version}"
-    )
+    msg = f"{_type} {old} which will be removed in version {version}, use {new} instead"
     if reason is not None:
         msg = f"{msg}\n{reason}"
 
@@ -45,14 +43,14 @@ def deprecated(_type, old, new, version, reason=None, stack_level=3):
         _warn(msg, DeprecationWarning, stacklevel=stack_level)
 
 
-def discontinued(_type, name, version, reason=None, stack_level=3):
+def discontinued(_type, old, version, reason=None, stack_level=3):
     """convenience func to warn about discontinued attributes
 
     Parameters
     ----------
     _type
         should be one of class, method, function, argument
-    name
+    old
         the attributes name
     version
         the version by which support for the old name will be
@@ -62,9 +60,7 @@ def discontinued(_type, name, version, reason=None, stack_level=3):
     stack_level
         as per warnings.warn
     """
-    msg = (
-        f"{_type} {name} is discontinued, support will be stopped in version {version}"
-    )
+    msg = f"{_type} {old} is discontinued and will be removed in version {version}"
     if reason is not None:
         msg = f"{msg}\n{reason}"
 
@@ -133,3 +129,64 @@ def deprecated_args(
         return wrapper
 
     return decorator
+
+
+def deprecated_callable(
+    version: str,
+    reason: str,
+    new: Optional[str] = None,
+    is_discontinued: bool = False,
+) -> Callable:
+    """
+    A decorator that marks callables (function or method) as deprecated or discontinued..
+    Parameters
+    ----------
+    version : str
+        The version when it will be removed in calver format, e.g. 'YYYY.MM'
+    reason : str
+        Reason for deprecation or guidance on what to do
+    new : str
+        If the callable is being replaced, this is the replacement, e.g. 'ClassName.new_method()'
+    is_discontinued : bool
+        If True the callable is being discontinued.
+
+    Returns
+    -------
+    Callable
+        The decorated callable.
+
+    Warnings
+    --------
+    DeprecationWarning
+        A warning will be raised when the decorated function is called.
+
+    Examples
+    --------
+    Here's an example of how to use the `deprecated_callable` decorator to mark the function `my_function` as deprecated
+    in favour of a new function.
+
+    >>> @deprecated_callable(version='2023.6', reason='function rename', new='a_function')
+    >>> def my_function(arg): pass
+
+    """
+
+    def decorator(func: Callable) -> Callable:
+        _type = "method" if inspect.ismethod(func) else "function"
+        old = func.__name__
+        params = dict(
+            _type=_type, old=old, version=version, reason=reason, stack_level=2
+        )
+        if is_discontinued:
+            depr_func = discontinued
+        else:
+            params["new"] = new
+            depr_func = deprecated
+
+        @functools.wraps(func)
+        def wrapper(*args: Any, **kwargs: Any) -> Callable:
+            depr_func(**params)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator


Test patch:
diff --git a/tests/test_util/test_warning.py b/tests/test_util/test_warning.py
index 2a0d8bee0..72b013041 100644
--- a/tests/test_util/test_warning.py
+++ b/tests/test_util/test_warning.py
@@ -3,7 +3,7 @@ import warnings
 
 import pytest
 
-from cogent3.util.warning import deprecated_args
+from cogent3.util.warning import deprecated_args, deprecated_callable
 
 
 __author__ = "Richard Morris"
@@ -159,3 +159,60 @@ def test_method_deprecated_args_pickled():
     unpickled_foo.changed(x=2, y=3)
     assert unpickled_foo.a == 2
     assert unpickled_foo.b == 3
+
+
+class foo2:
+    # noting that new_meth does not exist
+    @deprecated_callable("2023.9", reason="test meth", new="new_meth")
+    def old_meth(self, v):
+        return v ** 2
+
+    @deprecated_callable("2023.9", reason="redundant", is_discontinued=True)
+    def squared(self, v):
+        return v * v
+
+
+@deprecated_callable("2023.9", reason="test func", new="new_func")
+def old_func(v):
+    return v ** 3
+
+
+@deprecated_callable("2023.9", reason="redundant", is_discontinued=True)
+def cubed(v):
+    return v * v
+
+
+@pytest.mark.parametrize("func", (foo2().old_meth, foo2().squared, old_func, cubed))
+def test_deprecated_callable_warn(func):
+    with pytest.deprecated_call():
+        func(2)
+
+
+@pytest.mark.parametrize("func", (cubed, old_func))
+def test_method_deprecated_function_pickling(recwarn, func):
+    # using pytest recwarn fixture here since the filterwarning decorator
+    # does not seem to play nice with parametrize
+    # serialize func with pickle
+    pickled_func = pickle.dumps(func)
+    assert isinstance(pickled_func, bytes)
+
+    # deserialize with pickle
+    unpickled_func = pickle.loads(pickled_func)
+
+    assert unpickled_func(20) == func(20)
+
+
+def test_method_deprecated_method_pickling(recwarn):
+    # serialize instance with pickle
+    instance = foo2()
+    pickled = pickle.dumps(instance)
+    assert isinstance(pickled, bytes)
+
+    # deserialize with pickle
+    unpickled = pickle.loads(pickled)
+
+    # the method still works after pickling
+    assert unpickled.old_meth(20) == instance.old_meth(20)
+
+    # the method still works after pickling
+    assert unpickled.squared(20) == instance.squared(20)