Repository: sunpy/sunpy

Issue:
Bugs with the message from the deprecation decorator
Our `deprecated` decorator has some recent bugs, possibly came in with #3124:

- The "scheduled for removal" version is calculated incorrectly.  I think it's an order-of-operations bug in this line: https://github.com/sunpy/sunpy/blob/1f79409372f8bd932c8f4db978e67bff451f04a7/sunpy/util/decorators.py#L48.
- The "scheduled for removal" message is always created (https://github.com/sunpy/sunpy/blob/1f79409372f8bd932c8f4db978e67bff451f04a7/sunpy/util/decorators.py#L56), which means that the code branch to create a default message that includes pointing the user to the specified `alternative` never runs (https://github.com/sunpy/sunpy/blob/1f79409372f8bd932c8f4db978e67bff451f04a7/sunpy/util/decorators.py#L164).


Discussion:
Woops.

Resolving patch:
diff --git a/sunpy/util/decorators.py b/sunpy/util/decorators.py
--- a/sunpy/util/decorators.py
+++ b/sunpy/util/decorators.py
@@ -7,28 +7,46 @@
 import warnings
 import functools
 
-from sunpy.util.exceptions import SunpyDeprecationWarning
+from sunpy.util.exceptions import SunpyDeprecationWarning, SunpyPendingDeprecationWarning
 
 __all__ = ['deprecated']
 
 
-def deprecated(since, message='', name='', alternative=''):
+def get_removal_version(since):
+    # Work out which version this will be removed in
+    since_major, since_minor = since.split('.')[:2]
+    since_lts = since_minor == '0'
+    if since_lts:
+        major = int(since_major)
+        minor = int(since_minor) + 1
+    else:
+        major = int(since_major) + 1
+        minor = 1
+    return major, minor
+
+
+def deprecated(since, message='', name='', alternative='', pending=False,
+               obj_type=None):
     """
     Used to mark a function or class as deprecated.
 
+    To mark an attribute as deprecated, use `deprecated_attribute`.
+
     Parameters
     ------------
-    since : `str`
+    since : str
         The release at which this API became deprecated.  This is
         required.
-    message : `str`, optional
+
+    message : str, optional
         Override the default deprecation message.  The format
         specifier ``func`` may be used for the name of the function,
         and ``alternative`` may be used in the deprecation message
         to insert the name of an alternative to the deprecated
         function. ``obj_type`` may be used to insert a friendly name
         for the type of object being deprecated.
-    name : `str`, optional
+
+    name : str, optional
         The name of the deprecated function or class; if not provided
         the name is automatically determined from the passed in
         function or class, though this is useful in the case of
@@ -39,34 +57,50 @@ def new_function():
                 ...
             oldFunction = new_function
 
-    alternative : `str`, optional
+    alternative : str, optional
         An alternative function or class name that the user may use in
-        place of the deprecated object. The deprecation warning will
+        place of the deprecated object.  The deprecation warning will
         tell the user about this alternative if provided.
+
+    pending : bool, optional
+        If True, uses a SunpyPendingDeprecationWarning instead of a
+        ``warning_type``.
+
+    obj_type : str, optional
+        The type of this object, if the automatically determined one
+        needs to be overridden.
+
+    warning_type : warning
+        Warning to be issued.
+        Default is `~sunpy.utils.exceptions.SunpyDeprecationWarning`.
     """
-    since_major, since_minor = since.split('.')[:2]
-    since_lts = since_minor == 0
-    if since_lts:
-        major = int(since_major)
-        minor = int(since_minor) + 1
-    else:
-        major = int(since_major) + 1
-        minor = 1
+    major, minor = get_removal_version(since)
     removal_version = f"{major}.{minor}"
-    message += f" This is scheduled for removal in {removal_version}."
-
+    # TODO: replace this with the astropy deprecated decorator
+    return _deprecated(since, message=message, name=name, alternative=alternative, pending=pending,
+                       removal_version=removal_version, obj_type=obj_type,
+                       warning_type=SunpyDeprecationWarning,
+                       pending_warning_type=SunpyPendingDeprecationWarning)
+
+
+def _deprecated(since, message='', name='', alternative='', pending=False, removal_version=None,
+                obj_type=None, warning_type=SunpyDeprecationWarning,
+                pending_warning_type=SunpyPendingDeprecationWarning):
+    # TODO: remove this once the removal_version kwarg has been added to the upstream
+    # astropy deprecated decorator
     method_types = (classmethod, staticmethod, types.MethodType)
 
     def deprecate_doc(old_doc, message):
         """
-        Returns a given docstring with a deprecation message prepended to it.
+        Returns a given docstring with a deprecation message prepended
+        to it.
         """
         if not old_doc:
             old_doc = ''
         old_doc = textwrap.dedent(old_doc).strip('\n')
         new_doc = (('\n.. deprecated:: {since}'
                     '\n    {message}\n\n'.format(
-                        **{'since': since, 'message': message.strip()})) + old_doc)
+                     **{'since': since, 'message': message.strip()})) + old_doc)
         if not old_doc:
             # This is to prevent a spurious 'unexpected unindent' warning from
             # docutils when the original docstring was blank.
@@ -82,24 +116,26 @@ def get_function(func):
             func = func.__func__
         return func
 
-    def deprecate_function(func, message):
+    def deprecate_function(func, message, warning_type=warning_type):
         """
-        Returns a wrapped function that displays an ``SunpyDeprecationWarning``
+        Returns a wrapped function that displays ``warning_type``
         when it is called.
         """
 
         if isinstance(func, method_types):
             func_wrapper = type(func)
         else:
-            def func_wrapper(f): return f  # noqa
+            func_wrapper = lambda f: f
 
         func = get_function(func)
 
         def deprecated_func(*args, **kwargs):
+            if pending:
+                category = pending_warning_type
+            else:
+                category = warning_type
 
-            category = SunpyDeprecationWarning
-
-            warnings.warn(message, category)
+            warnings.warn(message, category, stacklevel=2)
 
             return func(*args, **kwargs)
 
@@ -107,7 +143,7 @@ def deprecated_func(*args, **kwargs):
         # functools.wraps on it, but we normally don't care.
         # This crazy way to get the type of a wrapper descriptor is
         # straight out of the Python 3.3 inspect module docs.
-        if type(func) is not type(str.__dict__['__add__']):  # noqa
+        if type(func) is not type(str.__dict__['__add__']):  # nopep8
             deprecated_func = functools.wraps(func)(deprecated_func)
 
         deprecated_func.__doc__ = deprecate_doc(
@@ -115,69 +151,76 @@ def deprecated_func(*args, **kwargs):
 
         return func_wrapper(deprecated_func)
 
-    def deprecate_class(cls, message):
+    def deprecate_class(cls, message, warning_type=warning_type):
         """
-        Returns a wrapper class with the docstrings updated and an ``__init__``
-        function that will raise an ``SunpyDeprectationWarning`` warning when
-        called.
+        Update the docstring and wrap the ``__init__`` in-place (or ``__new__``
+        if the class or any of the bases overrides ``__new__``) so it will give
+        a deprecation warning when an instance is created.
+
+        This won't work for extension classes because these can't be modified
+        in-place and the alternatives don't work in the general case:
+
+        - Using a new class that looks and behaves like the original doesn't
+          work because the __new__ method of extension types usually makes sure
+          that it's the same class or a subclass.
+        - Subclassing the class and return the subclass can lead to problems
+          with pickle and will look weird in the Sphinx docs.
         """
-        # Creates a new class with the same name and bases as the
-        # original class, but updates the dictionary with a new
-        # docstring and a wrapped __init__ method.  __module__ needs
-        # to be manually copied over, since otherwise it will be set
-        # to *this* module (astropy.utils.misc).
-
-        # This approach seems to make Sphinx happy (the new class
-        # looks enough like the original class), and works with
-        # extension classes (which functools.wraps does not, since
-        # it tries to modify the original class).
-
-        # We need to add a custom pickler or you'll get
-        #     Can't pickle <class ..>: it's not found as ...
-        # errors. Picklability is required for any class that is
-        # documented by Sphinx.
-
-        members = cls.__dict__.copy()
-
-        members.update({
-            '__doc__': deprecate_doc(cls.__doc__, message),
-            '__init__': deprecate_function(get_function(cls.__init__),
-                                           message),
-        })
-
-        return type(cls)(cls.__name__, cls.__bases__, members)
-
-    def deprecate(obj, message=message, name=name, alternative=alternative):
-        if isinstance(obj, type):
-            obj_type_name = 'class'
-        elif inspect.isfunction(obj):
-            obj_type_name = 'function'
-        elif inspect.ismethod(obj) or isinstance(obj, method_types):
-            obj_type_name = 'method'
+        cls.__doc__ = deprecate_doc(cls.__doc__, message)
+        if cls.__new__ is object.__new__:
+            cls.__init__ = deprecate_function(get_function(cls.__init__),
+                                              message, warning_type)
+        else:
+            cls.__new__ = deprecate_function(get_function(cls.__new__),
+                                             message, warning_type)
+        return cls
+
+    def deprecate(obj, message=message, name=name, alternative=alternative,
+                  pending=pending, warning_type=warning_type):
+        if obj_type is None:
+            if isinstance(obj, type):
+                obj_type_name = 'class'
+            elif inspect.isfunction(obj):
+                obj_type_name = 'function'
+            elif inspect.ismethod(obj) or isinstance(obj, method_types):
+                obj_type_name = 'method'
+            else:
+                obj_type_name = 'object'
         else:
-            obj_type_name = 'object'
+            obj_type_name = obj_type
 
         if not name:
             name = get_function(obj).__name__
 
         altmessage = ''
         if not message or type(message) is type(deprecate):
-            message = ('The {func} {obj_type} is deprecated and may '
-                       'be removed in a future version.')
+            if pending:
+                message = ('The {func} {obj_type} will be deprecated in '
+                           'version {deprecated_version}.')
+            else:
+                message = ('The {func} {obj_type} is deprecated and may '
+                           'be removed in {future_version}.')
             if alternative:
                 altmessage = f'\n        Use {alternative} instead.'
 
+        if removal_version is None:
+            future_version = 'a future version'
+        else:
+            future_version = f'version {removal_version}'
+
         message = ((message.format(**{
             'func': name,
             'name': name,
+            'deprecated_version': since,
+            'future_version': future_version,
             'alternative': alternative,
             'obj_type': obj_type_name})) +
             altmessage)
 
         if isinstance(obj, type):
-            return deprecate_class(obj, message)
+            return deprecate_class(obj, message, warning_type)
         else:
-            return deprecate_function(obj, message)
+            return deprecate_function(obj, message, warning_type)
 
     if type(message) is type(deprecate):
         return deprecate(message)


Test patch:
diff --git a/sunpy/util/tests/test_decorators.py b/sunpy/util/tests/test_decorators.py
new file mode 100644
--- /dev/null
+++ b/sunpy/util/tests/test_decorators.py
@@ -0,0 +1,39 @@
+import warnings
+import pytest
+
+from sunpy.util.decorators import get_removal_version, deprecated
+from sunpy.util.exceptions import SunpyDeprecationWarning, SunpyPendingDeprecationWarning
+
+
+def test_removal_version_since_lts():
+    major, minor = get_removal_version('2.0')
+    assert major == 2
+    assert minor == 1
+
+
+def test_removal_version_not_since_lts():
+    major, minor = get_removal_version('2.1')
+    assert major == 3
+    assert minor == 1
+
+
+@pytest.mark.parametrize(
+    'since,pending,warning,message,warning_message',
+    [
+        ('2.0', False, SunpyDeprecationWarning, '',
+         'The foo function is deprecated and may be removed in version 2.1.'),
+        ('2.1', False, SunpyDeprecationWarning, '',
+         'The foo function is deprecated and may be removed in version 3.1.'),
+        ('2.0', True, SunpyPendingDeprecationWarning, '',
+         'The foo function will be deprecated in version 2.0.'),
+        ('2.0', False, SunpyDeprecationWarning,
+         'Custom deprecation message', 'Custom deprecation message'),
+    ]
+)
+def test_deprecated_warning_message(since, pending, warning, message, warning_message):
+    @deprecated(since, pending=pending, message=message)
+    def foo():
+        pass
+    with pytest.warns(warning, match=warning_message):
+        warnings.simplefilter('always')
+        foo()