Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add deprecation decorators #3174

Merged
merged 8 commits into from Jul 19, 2023
28 changes: 28 additions & 0 deletions doc/dev_guide/coding_style.rst
Expand Up @@ -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 <black@email.com>" -m "Automatic style
corrections courtesy of black"

Deprecations
============
Hyperspy follows `semetic versioning <https://semver.org>`_ where changes follow such that:
CSSFrancis marked this conversation as resolved.
Show resolved Hide resolved

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.

The decorator should be placed right above the object signature to be deprecated:
CSSFrancis marked this conversation as resolved.
Show resolved Hide resolved

@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 are copied from work by Håkon Wiik Ånes and kikchipy.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this note is nice, I suggest to rephrase to something like "The deprecation wrappers are inspired by those in kikuchipy". Or, I'm OK with also just removing it: so much of kikuchipy is inspired by how things are handled in HyperSpy, without exhaustive references back to HyperSpy (although there are a few), so I'm just happy that some functionality "flows" the other way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good :) I'll just leave that they came from kikuchipy.

I do think they are quite a good way to handle deprecations and it might be good to promote them for wider usage. If someone takes them from hyperspy it would be good to have the credit make it's way back to you!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that is more than enough (:

124 changes: 124 additions & 0 deletions hyperspy/decorators.py
Expand Up @@ -17,7 +17,11 @@
# along with HyperSpy. If not, see <https://www.gnu.org/licenses/#GPL>.

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
Expand Down Expand Up @@ -106,3 +110,123 @@ def wrapper(self, *args, **kwargs):
else:
cm(self, *args, **kwargs)
return wrapper


class deprecated:
"""Decorator to mark deprecated functions with an informative
warning.
Adapted from
CSSFrancis marked this conversation as resolved.
Show resolved Hide resolved
`scikit-image
<https://github.com/scikit-image/scikit-image/blob/main/skimage/_shared/utils.py>`_
and `matplotlib
<https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/_api/deprecation.py>`_.
CSSFrancis marked this conversation as resolved.
Show resolved Hide resolved
"""

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
CSSFrancis marked this conversation as resolved.
Show resolved Hide resolved
----------
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
CSSFrancis marked this conversation as resolved.
Show resolved Hide resolved
<https://github.com/scikit-image/scikit-image/blob/main/skimage/_shared/utils.py>`_.
"""

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
134 changes: 134 additions & 0 deletions 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 <https://www.gnu.org/licenses/#GPL>.


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 record2:
assert foo2(4) == 6
desired_msg2 = "Function `foo2()` is deprecated."
assert str(record2[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 record2:
CSSFrancis marked this conversation as resolved.
Show resolved Hide resolved
assert my_foo.bar_arg(a=2) == {"a": 2}
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`. See the documentation of "
r"`bar_arg()` for more details."
)

# Warns with alternative
with pytest.warns(np.VisibleDeprecationWarning) as record3:
assert my_foo.bar_arg_alt(a=3) == {"b": 3}
assert str(record3[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."
)
3 changes: 3 additions & 0 deletions 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