"""Function for recording and reporting deprecations.
Notes
-----
this file is copied (with minor modifications) from the Nibabel.
https://github.com/nipy/nibabel. See COPYING file distributed along with
the Nibabel package for the copyright and license terms.
"""
import functools
import warnings
import re
from inspect import signature
from fury import __version__
from fury.optpkg import optional_package
# packaging.version.parse is a third-party utility but is used by setuptools
# (so probably already installed) and is conformant to the current PEP 440.
# But just if it is not the case, we use distutils
packaging, have_pkg, _ = optional_package('setuptools.extern.packaging')
_LEADING_WHITE = re.compile(r'^(\s*)')
[docs]class ExpiredDeprecationError(RuntimeError):
"""Error for expired deprecation.
Error raised when a called function or method has passed out of its
deprecation period.
"""
pass
[docs]class ArgsDeprecationWarning(DeprecationWarning):
"""Warning for args deprecation.
Warning raised when a function or method argument has changed or removed.
"""
pass
def _ensure_cr(text):
"""Remove trailing whitespace and add carriage return.
Ensures that `text` always ends with a carriage return
"""
return text.rstrip() + '\n'
def _add_dep_doc(old_doc, dep_doc):
"""Add deprecation message `dep_doc` to docstring in `old_doc`.
Parameters
----------
old_doc : str
Docstring from some object.
dep_doc : str
Deprecation warning to add to top of docstring, after initial line.
Returns
-------
new_doc : str
`old_doc` with `dep_doc` inserted after any first lines of docstring.
"""
dep_doc = _ensure_cr(dep_doc)
if not old_doc:
return dep_doc
old_doc = _ensure_cr(old_doc)
old_lines = old_doc.splitlines()
new_lines = []
for line_no, line in enumerate(old_lines):
if line.strip():
new_lines.append(line)
else:
break
next_line = line_no + 1
if next_line >= len(old_lines):
# nothing following first paragraph, just append message
return old_doc + '\n' + dep_doc
indent = _LEADING_WHITE.match(old_lines[next_line]).group()
dep_lines = [indent + L for L in [''] + dep_doc.splitlines() + ['']]
return '\n'.join(new_lines + dep_lines + old_lines[next_line:]) + '\n'
[docs]def cmp_pkg_version(version_str, pkg_version_str=__version__):
"""Compare `version_str` to current package version.
Parameters
----------
version_str : str
Version string to compare to current package version
pkg_version_str : str, optional
Version of our package. Optional, set fom ``__version__`` by default.
Returns
-------
version_cmp : int
1 if `version_str` is a later version than `pkg_version_str`, 0 if
same, -1 if earlier.
Examples
--------
>>> cmp_pkg_version('1.2.1', '1.2.0')
1
>>> cmp_pkg_version('1.2.0dev', '1.2.0')
-1
"""
version_cmp = packaging.version.parse if have_pkg else None
if any([re.match(r'^[a-z, A-Z]', v)for v in [version_str,
pkg_version_str]]):
msg = 'Invalid version {0} or {1}'.format(version_str, pkg_version_str)
raise ValueError(msg)
elif version_cmp(version_str) > version_cmp(pkg_version_str):
return 1
elif version_cmp(version_str) == version_cmp(pkg_version_str):
return 0
else:
return -1
[docs]def is_bad_version(version_str, version_comparator=cmp_pkg_version):
"""Return True if `version_str` is too high."""
return version_comparator(version_str) == -1
[docs]def deprecate_with_version(message, since='', until='',
version_comparator=cmp_pkg_version,
warn_class=DeprecationWarning,
error_class=ExpiredDeprecationError):
"""Return decorator function for deprecation warning / error.
The decorated function / method will:
* Raise the given `warning_class` warning when the function / method gets
called, up to (and including) version `until` (if specified);
* Raise the given `error_class` error when the function / method gets
called, when the package version is greater than version `until` (if
specified).
Parameters
----------
message : str
Message explaining deprecation, giving possible alternatives.
since : str, optional
Released version at which object was first deprecated.
until : str, optional
Last released version at which this function will still raise a
deprecation warning. Versions higher than this will raise an
error.
version_comparator : callable
Callable accepting string as argument, and return 1 if string
represents a higher version than encoded in the `version_comparator`, 0
if the version is equal, and -1 if the version is lower. For example,
the `version_comparator` may compare the input version string to the
current package version string.
warn_class : class, optional
Class of warning to generate for deprecation.
error_class : class, optional
Class of error to generate when `version_comparator` returns 1 for a
given argument of ``until``.
Returns
-------
deprecator : func
Function returning a decorator.
"""
messages = [message]
if (since, until) != ('', ''):
messages.append('')
if since:
messages.append('* deprecated from version: ' + since)
if until:
messages.append('* {0} {1} as of version: {2}'.format(
"Raises" if is_bad_version(until) else "Will raise",
error_class,
until))
message = '\n'.join(messages)
def deprecator(func):
@functools.wraps(func)
def deprecated_func(*args, **kwargs):
if until and is_bad_version(until, version_comparator):
raise error_class(message)
warnings.warn(message, warn_class, stacklevel=2)
return func(*args, **kwargs)
deprecated_func.__doc__ = _add_dep_doc(deprecated_func.__doc__,
message)
return deprecated_func
return deprecator
[docs]def deprecated_params(old_name, new_name=None, since='', until='',
version_comparator=cmp_pkg_version,
arg_in_kwargs=False,
warn_class=ArgsDeprecationWarning,
error_class=ExpiredDeprecationError,
alternative=''):
"""Deprecate a _renamed_ or _removed_ function argument.
The decorator assumes that the argument with the ``old_name`` was removed
from the function signature and the ``new_name`` replaced it at the
**same position** in the signature. If the ``old_name`` argument is
given when calling the decorated function the decorator will catch it and
issue a deprecation warning and pass it on as ``new_name`` argument.
Parameters
----------
old_name : str or list/tuple thereof
The old name of the argument.
new_name : str or list/tuple thereof or ``None``, optional
The new name of the argument. Set this to `None` to remove the
argument ``old_name`` instead of renaming it.
since : str or number or list/tuple thereof, optional
The release at which the old argument became deprecated.
until : str or number or list/tuple thereof, optional
Last released version at which this function will still raise a
deprecation warning. Versions higher than this will raise an
error.
version_comparator : callable
Callable accepting string as argument, and return 1 if string
represents a higher version than encoded in the ``version_comparator``,
0 if the version is equal, and -1 if the version is lower. For example,
the ``version_comparator`` may compare the input version string to the
current package version string.
arg_in_kwargs : bool or list/tuple thereof, optional
If the argument is not a named argument (for example it
was meant to be consumed by ``**kwargs``) set this to
``True``. Otherwise the decorator will throw an Exception
if the ``new_name`` cannot be found in the signature of
the decorated function.
Default is ``False``.
warn_class : warning, optional
Warning to be issued.
error_class : Exception, optional
Error to be issued
alternative : str, optional
An alternative function or class name that the user may use in
place of the deprecated object if ``new_name`` is None. The deprecation
warning will tell the user about this alternative if provided.
Raises
------
TypeError
If the new argument name cannot be found in the function
signature and arg_in_kwargs was False or if it is used to
deprecate the name of the ``*args``-, ``**kwargs``-like arguments.
At runtime such an Error is raised if both the new_name
and old_name were specified when calling the function and
"relax=False".
Notes
-----
This function is based on the Astropy (major modification).
https://github.com/astropy/astropy. See COPYING file distributed along with
the astropy package for the copyright and license terms.
Examples
--------
The deprecation warnings are not shown in the following examples.
To deprecate a positional or keyword argument::
>>> from fury.deprecator import deprecated_params
>>> @deprecated_params('sig', 'sigma', '0.3')
... def test(sigma):
... return sigma
>>> test(2)
2
>>> test(sigma=2)
2
>>> test(sig=2) # doctest: +SKIP
2
To deprecate an argument caught inside the ``**kwargs`` the
``arg_in_kwargs`` has to be set::
>>> @deprecated_params('sig', 'sigma', '1.0',
... arg_in_kwargs=True)
... def test(**kwargs):
... return kwargs['sigma']
>>> test(sigma=2)
2
>>> test(sig=2) # doctest: +SKIP
2
It is also possible to replace multiple arguments. The ``old_name``,
``new_name`` and ``since`` have to be `tuple` or `list` and contain the
same number of entries::
>>> @deprecated_params(['a', 'b'], ['alpha', 'beta'],
... ['0.2', 0.4])
... def test(alpha, beta):
... return alpha, beta
>>> test(a=2, b=3) # doctest: +SKIP
(2, 3)
"""
if isinstance(old_name, (list, tuple)):
# Normalize input parameters
if not isinstance(arg_in_kwargs, (list, tuple)):
arg_in_kwargs = [arg_in_kwargs] * len(old_name)
if not isinstance(since, (list, tuple)):
since = [since] * len(old_name)
if not isinstance(until, (list, tuple)):
until = [until] * len(old_name)
if not isinstance(new_name, (list, tuple)):
new_name = [new_name] * len(old_name)
if len(set([len(old_name), len(new_name), len(since),
len(until), len(arg_in_kwargs)])) != 1:
raise ValueError("All parameters should have the same length")
else:
# To allow a uniform approach later on, wrap all arguments in lists.
old_name = [old_name]
new_name = [new_name]
since = [since]
until = [until]
arg_in_kwargs = [arg_in_kwargs]
def deprecator(function):
# The named arguments of the function.
arguments = signature(function).parameters
positions = [None] * len(old_name)
for i, (o_name, n_name, in_keywords) in enumerate(zip(old_name,
new_name,
arg_in_kwargs)):
# Determine the position of the argument.
if in_keywords:
continue
if n_name is not None and n_name not in arguments:
# In case the argument is not found in the list of arguments
# the only remaining possibility is that it should be caught
# by some kind of **kwargs argument.
msg = '"{}" was not specified in the function '.format(n_name)
msg += 'signature. If it was meant to be part of '
msg += '"**kwargs" then set "arg_in_kwargs" to "True"'
raise TypeError(msg)
key = o_name if n_name is None else n_name
param = arguments[key]
if param.kind == param.POSITIONAL_OR_KEYWORD:
key = o_name if n_name is None else n_name
positions[i] = list(arguments.keys()).index(key)
elif param.kind == param.KEYWORD_ONLY:
# These cannot be specified by position.
positions[i] = None
else:
# positional-only argument, varargs, varkwargs or some
# unknown type:
msg = 'cannot replace argument "{}" '.format(n_name)
msg += 'of kind {}.'.format(repr(param.kind))
raise TypeError(msg)
@functools.wraps(function)
def wrapper(*args, **kwargs):
for i, (o_name, n_name) in enumerate(zip(old_name, new_name)):
messages = ['"{}" was deprecated'.format(o_name), ]
if (since[i], until[i]) != ('', ''):
messages.append('')
if since[i]:
messages.append('* deprecated from version: ' +
str(since[i]))
if until[i]:
messages.append('* {0} {1} as of version: {2}'.format(
"Raises" if is_bad_version(until[i]) else "Will raise",
error_class,
until[i]))
messages.append('')
message = '\n'.join(messages)
# The only way to have oldkeyword inside the function is
# that it is passed as kwarg because the oldkeyword
# parameter was renamed to newkeyword.
if o_name in kwargs:
value = kwargs.pop(o_name)
# Check if the newkeyword was given as well.
newarg_in_args = (positions[i] is not None and
len(args) > positions[i])
newarg_in_kwargs = n_name in kwargs
if newarg_in_args or newarg_in_kwargs:
msg = 'cannot specify both "{}"'.format(o_name)
msg += ' (deprecated parameter) and '
msg += '"{}" (new parameter name).'.format(n_name)
raise TypeError(msg)
# Pass the value of the old argument with the
# name of the new argument to the function
key = n_name or o_name
kwargs[key] = value
if n_name is not None:
message += '* Use argument "{}" instead.' \
.format(n_name)
elif alternative:
message += '* Use {} instead.'.format(alternative)
if until[i] and is_bad_version(until[i],
version_comparator):
raise error_class(message)
warnings.warn(message, warn_class, stacklevel=2)
# Deprecated keyword without replacement is given as
# positional argument.
elif (not n_name and positions[i] and
len(args) > positions[i]):
if alternative:
message += '* Use {} instead.'.format(alternative)
if until[i] and is_bad_version(until[i],
version_comparator):
raise error_class(message)
warnings.warn(message, warn_class, stacklevel=2)
return function(*args, **kwargs)
return wrapper
return deprecator