diff --git a/doc/dev_guide/coding_style.rst b/doc/dev_guide/coding_style.rst index 4c19d6a8a0..7e44b48010 100644 --- a/doc/dev_guide/coding_style.rst +++ b/doc/dev_guide/coding_style.rst @@ -35,3 +35,31 @@ adding a ``post-commit`` file to ``.git/hook/`` with the following content: GIT_COMMITTER_NAME="black" GIT_COMMITTER_EMAIL="black@email.com" git commit --author="black " -m "Automatic style corrections courtesy of black" + +Deprecations +============ +HyperSpy follows `semantic versioning `_ where changes follow such that: + +1. MAJOR version when you make incompatible API changes +2. MINOR version when you add functionality in a backward compatible manner +3. PATCH version when you make backward compatible bug fixes + +This means that as little, ideally no, functionality should break between minor releases. +Deprecation warnings are raised whenever possible and feasible for functions/methods/properties/arguments, +so that users get a heads-up one (minor) release before something is removed or changes, with a possible +alternative to be used. + +A deprecation decorator should be placed right above the object signature to be deprecated: + +@deprecated(since=1.7.0, removal=2.0.0, alternative="bar") +def foo(self, n): + return n + 1 + +@deprecated_argument(since=1.7.0, removal=2.0.0,name="x", alternative="y") +def this_property(y): + return y + +This will update the docstring, and print a visible deprecation warning telling the user to user the +alternative function or argument. + +These deprecation wrappers inspired by those in `kikuchipy` \ No newline at end of file diff --git a/hyperspy/decorators.py b/hyperspy/decorators.py index 5ccbee0ae6..06060f2d36 100644 --- a/hyperspy/decorators.py +++ b/hyperspy/decorators.py @@ -17,7 +17,11 @@ # along with HyperSpy. If not, see . from functools import wraps +import inspect +from typing import Callable, Optional, Union +import warnings +import numpy as np def lazify(func, **kwargs): from hyperspy.signal import BaseSignal @@ -106,3 +110,126 @@ def wrapper(self, *args, **kwargs): else: cm(self, *args, **kwargs) return wrapper + + +class deprecated: + """Decorator to mark deprecated functions with an informative + warning. + + Inspired by + `scikit-image + `_ + and `matplotlib + `_. + """ + + def __init__( + self, + since: Union[str, int, float], + alternative: Optional[str] = None, + alternative_is_function: bool = True, + removal: Union[str, int, float, None] = None, + ): + """Visible deprecation warning. + + Parameters + ---------- + since + The release at which this API became deprecated. + alternative + An alternative API that the user may use in place of the + deprecated API. + alternative_is_function + Whether the alternative is a function. Default is ``True``. + removal + The expected removal version. + """ + self.since = since + self.alternative = alternative + self.alternative_is_function = alternative_is_function + self.removal = removal + + def __call__(self, func: Callable): + # Wrap function to raise warning when called, and add warning to + # docstring + if self.alternative is not None: + if self.alternative_is_function: + alt_msg = f" Use `{self.alternative}()` instead." + else: + alt_msg = f" Use `{self.alternative}` instead." + else: + alt_msg = "" + if self.removal is not None: + rm_msg = f" and will be removed in version {self.removal}" + else: + rm_msg = "" + msg = f"Function `{func.__name__}()` is deprecated{rm_msg}.{alt_msg}" + + @wraps(func) + def wrapped(*args, **kwargs): + warnings.simplefilter( + action="always", category=np.VisibleDeprecationWarning, append=True + ) + func_code = func.__code__ + warnings.warn_explicit( + message=msg, + category=np.VisibleDeprecationWarning, + filename=func_code.co_filename, + lineno=func_code.co_firstlineno + 1, + ) + return func(*args, **kwargs) + + # Modify docstring to display deprecation warning + old_doc = inspect.cleandoc(func.__doc__ or "").strip("\n") + notes_header = "\nNotes\n-----" + new_doc = ( + f"[*Deprecated*] {old_doc}\n" + f"{notes_header if notes_header not in old_doc else ''}\n" + f".. deprecated:: {self.since}\n" + f" {msg.strip()}" # Matplotlib uses three spaces + ) + wrapped.__doc__ = new_doc + + return wrapped + + +class deprecated_argument: + """Decorator to remove an argument from a function or method's + signature. + + Adapted from `scikit-image + `_. + """ + + def __init__(self, name, since, removal, alternative=None): + self.name = name + self.since = since + self.removal = removal + self.alternative = alternative + + def __call__(self, func): + @wraps(func) + def wrapped(*args, **kwargs): + if self.name in kwargs.keys(): + msg = ( + f"Argument `{self.name}` is deprecated and will be removed in " + f"version {self.removal}. To avoid this warning, please do not use " + f"`{self.name}`. " + ) + if self.alternative is not None: + msg += f"Use `{self.alternative}` instead. " + kwargs[self.alternative] = kwargs.pop(self.name) # replace with alternative kwarg + msg += f"See the documentation of `{func.__name__}()` for more details." + warnings.simplefilter( + action="always", category=np.VisibleDeprecationWarning + ) + func_code = func.__code__ + warnings.warn_explicit( + message=msg, + category=np.VisibleDeprecationWarning, + filename=func_code.co_filename, + lineno=func_code.co_firstlineno + 1, + ) + return func(*args, **kwargs) + + return wrapped \ No newline at end of file diff --git a/hyperspy/tests/test_decorators.py b/hyperspy/tests/test_decorators.py new file mode 100644 index 0000000000..3693c4af23 --- /dev/null +++ b/hyperspy/tests/test_decorators.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright 2007-2023 The HyperSpy developers +# +# This file is part of HyperSpy. +# +# HyperSpy is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# HyperSpy is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with HyperSpy. If not, see . + + +import warnings + +import numpy as np +import pytest + +from hyperspy.decorators import deprecated, deprecated_argument + + +class TestDeprecationWarning: + def test_deprecation_since(self): + """Ensure functions decorated with the custom deprecated + decorator returns desired output, raises a desired warning, and + gets the desired additions to their docstring. + """ + + @deprecated(since=0.7, alternative="bar", removal=0.8) + def foo(n): + """Some docstring.""" + return n + 1 + + with pytest.warns(np.VisibleDeprecationWarning) as record: + assert foo(4) == 5 + desired_msg = ( + "Function `foo()` is deprecated and will be removed in version 0.8. Use " + "`bar()` instead." + ) + assert str(record[0].message) == desired_msg + assert foo.__doc__ == ( + "[*Deprecated*] Some docstring.\n\n" + "Notes\n-----\n" + ".. deprecated:: 0.7\n" + f" {desired_msg}" + ) + + @deprecated(since=1.9) + def foo2(n): + """Another docstring. + Notes + ----- + Some existing notes. + """ + return n + 2 + + with pytest.warns(np.VisibleDeprecationWarning) as record: + assert foo2(4) == 6 + desired_msg2 = "Function `foo2()` is deprecated." + assert str(record[0].message) == desired_msg2 + assert foo2.__doc__ == ( + "[*Deprecated*] Another docstring." + "\nNotes\n-----\n" + "Some existing notes.\n\n" + ".. deprecated:: 1.9\n" + f" {desired_msg2}" + ) + + def test_deprecation_no_old_doc(self): + @deprecated(since=0.7, alternative="bar", removal=0.8) + def foo(n): + return n + 1 + + with pytest.warns(np.VisibleDeprecationWarning) as record: + assert foo(4) == 5 + desired_msg = ( + "Function `foo()` is deprecated and will be removed in version 0.8. Use " + "`bar()` instead." + ) + assert str(record[0].message) == desired_msg + assert foo.__doc__ == ( + "[*Deprecated*] \n" + "\nNotes\n-----\n" + ".. deprecated:: 0.7\n" + f" {desired_msg}" + ) + + +class TestDeprecateArgument: + def test_deprecate_argument(self): + """Functions decorated with the custom `deprecated_argument` + decorator returns desired output and raises a desired warning + only if the argument is passed. + """ + + class Foo: + @deprecated_argument(name="a", since="1.3", removal="1.4") + def bar_arg(self, **kwargs): + return kwargs + + @deprecated_argument(name="a", since="1.3", removal="1.4", alternative="b") + def bar_arg_alt(self, **kwargs): + return kwargs + + my_foo = Foo() + + # Does not warn + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert my_foo.bar_arg(b=1) == {"b": 1} + + # Warns + with pytest.warns(np.VisibleDeprecationWarning) as record1: + assert my_foo.bar_arg(a=2) == {"a": 2} + assert str(record1[0].message) == ( + r"Argument `a` is deprecated and will be removed in version 1.4. " + r"To avoid this warning, please do not use `a`. See the documentation of " + r"`bar_arg()` for more details." + ) + + # Warns with alternative + with pytest.warns(np.VisibleDeprecationWarning) as record2: + assert my_foo.bar_arg_alt(a=3) == {"b": 3} + assert str(record2[0].message) == ( + r"Argument `a` is deprecated and will be removed in version 1.4. " + r"To avoid this warning, please do not use `a`. Use `b` instead. See the " + r"documentation of `bar_arg_alt()` for more details." + ) diff --git a/upcoming_changes/3174.new.rst b/upcoming_changes/3174.new.rst new file mode 100644 index 0000000000..a3a76708c6 --- /dev/null +++ b/upcoming_changes/3174.new.rst @@ -0,0 +1,3 @@ +Added :class:`~.dectorators.deprecated` and :class:`~.dectorators.deprecated_argument`: +- Provide consistent and clean deprecation +- Added a guide for deprecating code \ No newline at end of file