Source code for fury.deprecator

"""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
from inspect import signature
import re
import warnings

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 from ``__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 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