Source code for fury.decorators

"""
Decorators for FURY tests and functions.

This module provides decorators to handle doctests, parameter
validation, and keyword-only arguments enforcement.
"""

from functools import wraps
from inspect import signature
import platform
import re
import sys
from warnings import warn

from packaging import version

import fury

skip_linux = is_linux = platform.system().lower() == "linux"
skip_osx = is_osx = platform.system().lower() == "darwin"
skip_win = is_win = platform.system().lower() == "windows"
is_py35 = sys.version_info.major == 3 and sys.version_info.minor == 5
SKIP_RE = re.compile(r"(\s*>>>.*?)(\s*)#\s*skip\s+if\s+(.*)$")


[docs] def doctest_skip_parser(func): """ Replace custom skip test markup in doctests. Parameters ---------- func : callable Function whose docstring will be modified. Returns ------- callable Function with modified docstring. Notes ----- Say a function has a docstring:: something # skip if not HAVE_AMODULE something + else something # skip if HAVE_BMODULE This decorator will evaluate the expression after ``skip if``. If this evaluates to True, then the comment is replaced by ``# doctest: +SKIP``. If False, then the comment is just removed. The expression is evaluated in the ``globals`` scope of `func`. For example, if the module global ``HAVE_AMODULE`` is False, and module global ``HAVE_BMODULE`` is False, the returned function will have docstring:: something # doctest: +SKIP something + else something """ lines = func.__doc__.split("\n") new_lines = [] for line in lines: match = SKIP_RE.match(line) if match is None: new_lines.append(line) continue code, space, expr = match.groups() if eval(expr, func.__globals__): code = code + space + "# doctest: +SKIP" new_lines.append(code) func.__doc__ = "\n".join(new_lines) return func
[docs] def warn_on_args_to_kwargs( from_version="0.11.0", until_version="0.14.0", ): """ Enforce keyword-only arguments. Parameters ---------- from_version : str, optional The version of FURY from which the function was supported. until_version : str, optional The version of FURY until which the function was supported. Returns ------- callable Decorator function. Examples -------- >>> from fury.decorators import warn_on_args_to_kwargs >>> import fury >>> @warn_on_args_to_kwargs() ... def f(a, b, *, c, d=1, e=1): ... return a + b + c + d + e >>> CURRENT_VERSION = fury.__version__ >>> fury.__version__ = "0.11.0" >>> f(1, 2, 3, 4, 5) 15 >>> f(1, 2, c=3, d=4, e=5) 15 >>> f(1, 2, 2, 4, e=5) 14 >>> f(1, 2, c=3, d=4) 11 >>> f(1, 2, d=3, e=5) Traceback (most recent call last): ... TypeError: f() missing 1 required keyword-only argument: 'c' """ # noqa: E501 def decorator(func): """ Enforce keyword-only arguments in a function. Parameters ---------- func : callable Function to be decorated. Returns ------- callable Decorated function. """ @wraps(func) def wrapper(*args, **kwargs): """ Handle function arguments and enforce keyword-only arguments. Parameters ---------- *args : tuple Positional arguments to be passed to the function. **kwargs : dict Keyword arguments to be passed to the function. Returns ------- any Result of the function call. Raises ------ TypeError If required keyword-only arguments are missing and FURY version is greater than until_version. """ sig = signature(func) params = sig.parameters # KEYWORD_ONLY_ARGS = [ arg.name for arg in params.values() if arg.kind == arg.KEYWORD_ONLY ] POSITIONAL_ARGS = [ arg.name for arg in params.values() if arg.kind in (arg.POSITIONAL_OR_KEYWORD, arg.POSITIONAL_ONLY) ] # Keyword-only arguments that do not have default values and not in kwargs missing_kwargs = [ arg for arg in KEYWORD_ONLY_ARGS if arg not in kwargs and params[arg].default == params[arg].empty ] # Keyword-only arguments that have default values ARG_DEFAULT = [ arg for arg in KEYWORD_ONLY_ARGS if arg not in kwargs and params[arg].default != params[arg].empty ] func_params_sample = [] # Create a sample of the function parameters for arg in params.values(): if arg.kind in (arg.POSITIONAL_OR_KEYWORD, arg.POSITIONAL_ONLY): func_params_sample.append(f"{arg.name}_value") elif arg.kind == arg.KEYWORD_ONLY: func_params_sample.append(f"{arg.name}='value'") func_params_sample = ", ".join(func_params_sample) args_kwargs_len = len(args) + len(kwargs) params_len = len(params) try: return func(*args, **kwargs) except TypeError as e: FURY_CURRENT_VERSION = fury.__version__ if ARG_DEFAULT: missing_kwargs += ARG_DEFAULT if missing_kwargs and params_len >= args_kwargs_len: # if the version of fury is greater than until_version, # an error should be displayed, if version.parse(FURY_CURRENT_VERSION) > version.parse( until_version ): raise TypeError(e) from e positional_args_len = len(POSITIONAL_ARGS) args_k = list(args[positional_args_len:]) args = list(args[:positional_args_len]) kwargs.update(dict(zip(missing_kwargs, args_k, strict=False))) result = func(*args, **kwargs) # if from_version is less or equal to fury.__version__ and, # less or equal to until_version # a warning should be displayed. if ( version.parse(from_version) <= version.parse(FURY_CURRENT_VERSION) <= version.parse(until_version) ): warn( f"We'll no longer accept the way you call the " f"{func.__name__} function in future versions of FURY.\n\n" "Here's how to call the Function {}: {}({})\n".format( func.__name__, func.__name__, func_params_sample ), UserWarning, stacklevel=3, ) # if the current version of fury is less than from_version, # the function should be called without any changes. return result return wrapper return decorator