from __future__ import annotations from functools import wraps import inspect from inspect import Parameter from inspect import Signature import os from pathlib import Path from typing import Callable from typing import TypeVar from typing import overload import warnings from typing_extensions import ParamSpec from pyvista._version import version_info _MAX_POSITIONAL_ARGS = 3 # Should match value in pyproject.toml P = ParamSpec('P') T = TypeVar('T') @overload def _deprecate_positional_args( func: Callable[P, T], *, version: tuple[int, int] = ..., allowed: list[str] | None = ..., n_allowed: int = ..., ) -> Callable[P, T]: ... @overload def _deprecate_positional_args( *, version: tuple[int, int] = ..., allowed: list[str] | None = ..., n_allowed: int = ... ) -> Callable[[Callable[P, T]], Callable[P, T]]: ... def _deprecate_positional_args( func: Callable[..., T] | None = None, *, version: tuple[int, int] = (0, 50), allowed: list[str] | None = None, n_allowed: int | None = None, ) -> Callable[..., T] | Callable[[Callable[P, T]], Callable[P, T]]: """Use a decorator to deprecate positional arguments. Parameters ---------- func : callable, default=None Function to check arguments on. version : tuple[int, int], default: (0, 50) The version (major, minor) when positional arguments will result in RuntimeError. allowed : list[str], optional List of argument names which are allowed to be positional. This value is limited based on rule PLR0917. n_allowed : int, optional Override the number of allowed positional arguments to this value. """ def _inner_deprecate_positional_args(f: Callable[P, T]) -> Callable[P, T]: def qualified_name() -> str: return f.__qualname__ if hasattr(f, '__qualname__') else f.__name__ decorator_name = _deprecate_positional_args.__name__ sig = inspect.signature(f) param_names = list(sig.parameters) # Validate n_allowed itself if n_allowed: if n_allowed <= _MAX_POSITIONAL_ARGS: msg = ( f'In decorator {decorator_name!r} for function {qualified_name()!r}:\n' f'`n_allowed` must be greater than {_MAX_POSITIONAL_ARGS} for it to be useful.' ) raise ValueError(msg) n_allowed_ = n_allowed else: n_allowed_ = _MAX_POSITIONAL_ARGS if allowed is not None: # Validate input type if not isinstance(allowed, list): msg = ( # type: ignore[unreachable] f'In decorator {decorator_name!r} for function {qualified_name()!r}:\n' f'Allowed arguments must be a list, got {type(allowed)}.' ) raise TypeError(msg) # Validate number of allowed args if len(allowed) > n_allowed_: msg = ( f'In decorator {decorator_name!r} for function {qualified_name()!r}:\n' f'A maximum of {n_allowed_} positional arguments are allowed.\n' f'Got {len(allowed)}: {allowed}' ) raise ValueError(msg) # Validate allowed against actual parameter names for name in allowed: if name not in param_names: msg = ( f'Allowed positional argument {name!r} in decorator ' f'{decorator_name!r}\n' f'is not a parameter of function {qualified_name()!r}.' ) raise ValueError(msg) # Check that allowed args appears in the same order as in the signature sig_allowed = [name for name in param_names if name in allowed] if sig_allowed != allowed: msg = ( f'The `allowed` list {allowed} in decorator {decorator_name!r} is not in the\n' f'same order as the parameters in {qualified_name()!r}.\n' f'Expected order: {sig_allowed}.' ) raise ValueError(msg) # Check that allowed args are not already kwonly for name in allowed: if sig.parameters[name].kind == Parameter.KEYWORD_ONLY: msg = ( f'Parameter {name!r} in decorator {decorator_name!r} is already ' f'keyword-only\nand should be removed from the allowed list.' ) raise ValueError(msg) # Check if the decorator is even needed at all n_positional = 0 for name in param_names: if name not in ['cls', 'self'] and sig.parameters[name].kind in [ Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD, ]: n_positional += 1 actual_n_allowed = len(allowed) if allowed else 0 if n_positional <= actual_n_allowed: msg = ( f'Function {qualified_name()!r} has {actual_n_allowed} positional arguments, ' f'which is less than or equal to the\nmaximum number of allowed positional ' f'arguments ({n_allowed_}).\nThis decorator is not necessary and can be removed.' ) raise RuntimeError(msg) # Raise error post-deprecation if version_info >= version: # Construct expected positional args and signature new_parameters = [] max_args_to_print = actual_n_allowed + 2 cls_or_self = 'cls' in param_names or 'self' in param_names max_args_to_print = (max_args_to_print + 1) if cls_or_self else max_args_to_print has_too_many_to_print = False for i, name in enumerate(param_names): if i > max_args_to_print: has_too_many_to_print = True break if name in ['cls', 'self', *(allowed if allowed else [])]: current_kind = sig.parameters[name].kind new_kind = ( current_kind if current_kind != Parameter.KEYWORD_ONLY else Parameter.KEYWORD_ONLY ) new_parameters.append(Parameter(name, kind=new_kind)) else: new_parameters.append(Parameter(name, kind=Parameter.KEYWORD_ONLY)) signature_string = f'{qualified_name()}{Signature(new_parameters)}' if has_too_many_to_print: # Replace ending bracket with ellipses signature_string = f'{signature_string[:-1]}, ...)' # Get source file and line number file = Path( os.path.relpath(inspect.getfile(f), start=os.getcwd()) # noqa: PTH109 # https://github.com/pyvista/pyvista/pull/7732 ).as_posix() lineno = inspect.getsourcelines(f)[1] location = f'{file}:{lineno}' msg = ( f'Positional arguments are no longer allowed in {qualified_name()!r}.\n' f'Update the function signature at:\n' f'{location} to enforce keyword-only args:\n' f' {signature_string}\n' f'and remove the {decorator_name!r} decorator.' ) raise RuntimeError(msg) @wraps(f) def inner_f(*args: P.args, **kwargs: P.kwargs) -> T: passed_positional_names = param_names[: len(args)] # Exclude allowed ones if allowed: offending_args = [name for name in passed_positional_names if name not in allowed] else: offending_args = passed_positional_names if 'self' in offending_args: offending_args.remove('self') if 'cls' in offending_args: offending_args.remove('cls') if offending_args: # Craft a message to print a warning or raise an error if len(offending_args) == 1: a = ' a ' s = '' this = 'this' else: a = ' ' s = 's' this = 'these' if version_info < version: # Print warning version_str = '.'.join(map(str, version)) arg_list = ', '.join(f'{a!r}' for a in offending_args) stack_level = 3 def call_site() -> str: # Get location where the function is called frame = inspect.stack()[stack_level] file = Path( os.path.relpath(frame.filename, start=os.getcwd()) # noqa: PTH109 # https://github.com/pyvista/pyvista/pull/7732 ).as_posix() return f'{file}:{frame.lineno}' def warn_positional_args() -> None: from pyvista.core.errors import PyVistaDeprecationWarning # noqa: PLC0415 msg = ( f'\n{call_site()}: ' f'Argument{s} {arg_list} must be passed as{a}keyword argument{s} ' f'to function {qualified_name()!r}.\n' f'From version {version_str}, passing {this} as{a}positional ' f'argument{s} will result in a TypeError.' ) warnings.warn(msg, PyVistaDeprecationWarning, stacklevel=stack_level) warn_positional_args() return f(*args, **kwargs) return inner_f if func is not None: return _inner_deprecate_positional_args(func) return _inner_deprecate_positional_args