Repository: mne-tools/mne-python

Issue:
MAINT: Add category of discouraged but non-deprecated functions
Increasingly it seems like we are keeping old APIs available by keeping tiny wrappers around new, better APIs. To give an example from https://github.com/mne-tools/mne-python/pull/10184, `epochs.plot_psd_topomap(...)` is kept around to avoid breaking code, but we really want people to use `EpochsSpectrum` methods.

I propose we add a new `@deprecated`-like decorator that marks such code as... something. Maybe "legacy" is a good term? And instead of the `.. warning::` that `@deprecated` produces, it could be a `.. info::` or so that says something like:

> This function is provided to support legacy code. New code should use the more complete API provided by {0}




Discussion:
+1, I can add this

Resolving patch:
diff --git a/mne/utils/__init__.py b/mne/utils/__init__.py
--- a/mne/utils/__init__.py
+++ b/mne/utils/__init__.py
@@ -29,7 +29,7 @@
                      sys_info, _get_extra_data_path, _get_root_dir,
                      _get_numpy_libs)
 from .docs import (copy_function_doc_to_method_doc, copy_doc, linkcode_resolve,
-                   open_docs, deprecated, fill_doc, deprecated_alias,
+                   open_docs, deprecated, fill_doc, deprecated_alias, legacy,
                    copy_base_doc_to_subclass_doc, docdict as _docdict)
 from .fetching import _url_to_local_path
 from ._logging import (verbose, logger, set_log_level, set_log_file,
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -4041,29 +4041,17 @@ def open_docs(kind=None, version=None):
     webbrowser.open_new_tab('https://mne.tools/%s/%s' % (version, kind))
 
 
-# Following deprecated class copied from scikit-learn
-
-# force show of DeprecationWarning even on python 2.7
+# make DeprecationWarnings actually show up for users
 warnings.filterwarnings('always', category=DeprecationWarning, module='mne')
 
 
-class deprecated:
-    """Mark a function, class, or method as deprecated (decorator).
-
-    Originally adapted from sklearn and
-    http://wiki.python.org/moin/PythonDecoratorLibrary, then modified to make
-    arguments populate properly following our verbose decorator methods based
-    on decorator.
+class _decorator:
+    """Inject code or modify the docstring of a class, method, or function."""
 
-    Parameters
-    ----------
-    extra : str
-        Extra information beyond just saying the class/function/method
-        is deprecated.
-    """
-
-    def __init__(self, extra=''):  # noqa: D102
+    def __init__(self, extra, kind):  # noqa: D102
+        self.kind = kind
         self.extra = extra
+        self.msg = f'{{}} is a {self.kind} {{}}. {self.extra}.'
 
     def __call__(self, obj):  # noqa: D105
         """Call.
@@ -4078,44 +4066,33 @@ def __call__(self, obj):  # noqa: D105
         obj : object
             The modified object.
         """
-        if isinstance(obj, type):
-            return self._decorate_class(obj)
+        if inspect.isclass(obj):
+            obj_type = 'class'
         else:
-            return self._decorate_fun(obj)
-
-    def _decorate_class(self, cls):
-        msg = f"Class {cls.__name__} is deprecated"
-        cls.__init__ = self._make_fun(cls.__init__, msg)
-        return cls
-
-    def _decorate_fun(self, fun):
-        """Decorate function fun."""
-        msg = f"Function {fun.__name__} is deprecated"
-        return self._make_fun(fun, msg)
-
-    def _make_fun(self, function, msg):
-        if self.extra:
-            msg += "; %s" % self.extra
-
-        body = f"""\
-def %(name)s(%(signature)s):\n
-    import warnings
-    warnings.warn({repr(msg)}, category=DeprecationWarning)
-    return _function_(%(shortsignature)s)"""
-        evaldict = dict(_function_=function)
+            # NB: detecting (bound and unbound) methods seems to be impossible
+            assert inspect.isfunction(obj), f'decorator used on {type(obj)}'
+            obj_type = 'function'
+        msg = self.msg.format(obj.__name__, obj_type)
+        if obj_type == 'class':
+            obj.__init__ = self._make_fun(obj.__init__, msg)
+            return obj
+        return self._make_fun(obj, msg)
+
+    def _make_fun(self, func, body):
+        evaldict = dict(_function_=func)
         fm = FunctionMaker(
-            function, None, None, None, None, function.__module__)
-        attrs = dict(__wrapped__=function, __qualname__=function.__qualname__,
-                     __globals__=function.__globals__)
+            func, None, None, None, None, func.__module__)
+        attrs = dict(__wrapped__=func, __qualname__=func.__qualname__,
+                     __globals__=func.__globals__)
         dep = fm.make(body, evaldict, addsource=True, **attrs)
         dep.__doc__ = self._update_doc(dep.__doc__)
-        dep._deprecated_original = function
+        dep._deprecated_original = func
         return dep
 
     def _update_doc(self, olddoc):
-        newdoc = ".. warning:: DEPRECATED"
+        newdoc = f".. warning:: {self.kind.upper()}"
         if self.extra:
-            newdoc = "%s: %s" % (newdoc, self.extra)
+            newdoc = f'{newdoc}: {self.extra}'
         newdoc += '.'
         if olddoc:
             # Get the spacing right to avoid sphinx warnings
@@ -4124,11 +4101,40 @@ def _update_doc(self, olddoc):
                 if li > 0 and len(line.strip()):
                     n_space = len(line) - len(line.lstrip())
                     break
-            newdoc = "%s\n\n%s%s" % (newdoc, ' ' * n_space, olddoc)
-
+            newdoc = f"{newdoc}\n\n{' ' * n_space}{olddoc}"
         return newdoc
 
 
+# Following deprecated class copied from scikit-learn
+class deprecated(_decorator):
+    """Mark a function, class, or method as deprecated (decorator).
+
+    Originally adapted from sklearn and
+    http://wiki.python.org/moin/PythonDecoratorLibrary, then modified to make
+    arguments populate properly following our verbose decorator methods based
+    on decorator.
+
+    Parameters
+    ----------
+    extra : str
+        Extra information beyond just saying the class/function/method is
+        deprecated. Should be a complete sentence (trailing period will be
+        added automatically). Will be included in DeprecationWarning messages
+        and in a sphinx warning box in the docstring.
+    """
+
+    def __init__(self, extra=''):
+        super().__init__(extra=extra, kind='deprecated')
+
+    def _make_fun(self, func, msg):
+        body = f"""\
+def %(name)s(%(signature)s):\n
+    import warnings
+    warnings.warn({repr(msg)}, category=DeprecationWarning)
+    return _function_(%(shortsignature)s)"""
+        return super()._make_fun(func=func, body=body)
+
+
 def deprecated_alias(dep_name, func, removed_in=None):
     """Inject a deprecated alias into the namespace."""
     if removed_in is None:
@@ -4143,6 +4149,37 @@ def deprecated_alias(dep_name, func, removed_in=None):
     )(deepcopy(func))
 
 
+###############################################################################
+# "legacy" decorator for parts of our API retained only for backward compat
+
+class legacy(_decorator):
+    """Mark a function, class, or method as legacy (decorator).
+
+    Parameters
+    ----------
+    alt : str
+        Description of the alternate, preferred way to achieve a comparable
+        result.
+    extra : str
+        Extra information beyond just saying the class/function/method is
+        legacy. Should be a complete sentence (trailing period will be
+        added automatically). Will be included in logger.info messages
+        and in a sphinx warning box in the docstring.
+    """
+
+    def __init__(self, alt, extra=''):  # noqa: D102
+        period = '. ' if len(extra) else ''
+        extra = f'New code should use {alt}{period}{extra}'
+        super().__init__(extra=extra, kind='legacy')
+
+    def _make_fun(self, func, msg):
+        body = f"""\
+def %(name)s(%(signature)s):\n
+    from mne.utils import logger
+    logger.info({repr(msg)})
+    return _function_(%(shortsignature)s)"""
+        return super()._make_fun(func=func, body=body)
+
 ###############################################################################
 # The following tools were adapted (mostly trimmed) from SciPy's doccer.py
 


Test patch:
diff --git a/mne/utils/tests/test_docs.py b/mne/utils/tests/test_docs.py
--- a/mne/utils/tests/test_docs.py
+++ b/mne/utils/tests/test_docs.py
@@ -3,7 +3,8 @@
 from mne import open_docs, grade_to_tris
 from mne.epochs import add_channels_epochs
 from mne.utils import (copy_function_doc_to_method_doc, copy_doc,
-                       linkcode_resolve, deprecated, deprecated_alias)
+                       linkcode_resolve, deprecated, deprecated_alias, legacy,
+                       catch_logging)
 import webbrowser
 
 
@@ -28,35 +29,65 @@ def new_func():
     assert 'deprecated' not in new_func.__doc__
 
 
-@deprecated('bad func')
+@deprecated('deprecated func')
 def deprecated_func():
     """Do something."""
     pass
 
 
-@deprecated('bad class')
-class deprecated_class(object):
+@legacy('replacement_func')
+def legacy_func():
+    """Do something."""
+    pass
+
+
+@deprecated('deprecated class')
+class deprecated_class():
 
     def __init__(self):
         pass
 
-    @deprecated('bad method')
+    @deprecated('deprecated method')
     def bad(self):
         pass
 
 
-def test_deprecated():
-    """Test deprecated function."""
-    assert 'DEPRECATED' in deprecated_func.__doc__
-    with pytest.deprecated_call(match='bad func'):
-        deprecated_func()
-    assert 'DEPRECATED' in deprecated_class.__init__.__doc__
-    with pytest.deprecated_call(match='bad class'):
-        dep = deprecated_class()
-    assert 'DEPRECATED' in deprecated_class.bad.__doc__
-    assert 'DEPRECATED' in dep.bad.__doc__
-    with pytest.deprecated_call(match='bad method'):
-        dep.bad()
+@legacy('replacement_class')
+class legacy_class():  # noqa D101
+
+    def __init__(self):
+        pass
+
+    @legacy('replacement_method')
+    def bad(self):  # noqa D102
+        pass
+
+
+@pytest.mark.parametrize(
+    ('msg', 'klass', 'func'),
+    (('deprecated', deprecated_class, deprecated_func),
+     ('legacy', legacy_class, legacy_func)))
+def test_deprecated_and_legacy(msg, func, klass):
+    """Test deprecated and legacy decorators."""
+    if msg == 'deprecated':
+        with pytest.deprecated_call(match=f'{msg} class'):
+            _klass = klass()
+        with pytest.deprecated_call(match=f'{msg} method'):
+            _klass.bad()
+        with pytest.deprecated_call(match=f'{msg} func'):
+            func()
+    else:
+        with catch_logging(verbose='info') as log:
+            _klass = klass()
+            _klass.bad()
+            func()
+        log = log.getvalue()
+        for kind in ('class', 'method', 'func'):
+            assert f'New code should use replacement_{kind}' in log
+    assert msg.upper() in klass.__init__.__doc__
+    assert msg.upper() in klass.bad.__doc__
+    assert msg.upper() in _klass.bad.__doc__
+    assert msg.upper() in func.__doc__
 
 
 def test_copy_doc():