252 lines
9.8 KiB
Python
252 lines
9.8 KiB
Python
|
|
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
|