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 Deprecations #199

Merged
merged 7 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ Unreleased
Added
-----
- Explicit support for Python 3.11.
- Added deprecation tools for deprecating functions and arguments.
- Added Pre-Commit for code formatting.

- Added deprecation tools for deprecating functions, parameters, methods, and properties.

Changed
-------
Expand Down
23 changes: 23 additions & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,29 @@ Useful hints on testing:
error-prone. See `pytest documentation for more details
<https://doc.pytest.org/en/latest/how-to/parametrize.html>`_.


Deprecations
------------
We attempt to adhere to semantic versioning as best we can. 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::

.. code-block:: python
from diffsims.utils._deprecated import deprecate
@deprecate(since=0.8, removal=0.9, alternative="bar")
def foo(self, n):
return n + 1

@property
@deprecate(since=0.9, removal=0.10, alternative="another", is_function=True)
def this_property(self):
return 2


Build and write documentation
-----------------------------

Expand Down
1 change: 0 additions & 1 deletion diffsims/crystallography/reciprocal_lattice_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
get_refraction_corrected_wavelength,
)


_FLOAT_EPS = np.finfo(float).eps # Used to round values below 1e-16 to zero


Expand Down
136 changes: 136 additions & 0 deletions diffsims/tests/utils/test_deprecation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import warnings

import numpy as np
import pytest

from diffsims.utils._deprecated 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}"
)

def test_deprecation_not_function(self):
@deprecated(
since=0.7, alternative="bar", removal=0.8, alternative_is_function=False
)
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:
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."
)
157 changes: 157 additions & 0 deletions diffsims/utils/_deprecated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
# Copyright 2017-2023 The diffsims developers
#
# This file is part of diffsims.
#
# diffsims 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.
#
# diffsims 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 diffsims. If not, see <http://www.gnu.org/licenses/>.


"""Helper functions and classes for managing diffsims.

This module and documentation is only relevant for diffsims developers,
not for users.

.. warning:
This module and its submodules are for internal use only. Do not
use them in your own code. We may change the API at any time with no
warning.
"""

import functools
import inspect
from typing import Callable, Optional, Union
import warnings

import numpy as np


class deprecated:
"""Decorator to mark deprecated functions with an informative
warning.

Adapted from
`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>`_.
"""

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}"

@functools.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
<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):
@functools.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)
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
Loading