File: _deprecate_positional_args.py

package info (click to toggle)
python-pyvista 0.46.5-6
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 178,808 kB
  • sloc: python: 94,599; sh: 216; makefile: 70
file content (251 lines) | stat: -rw-r--r-- 10,069 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
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

from typing_extensions import ParamSpec

from pyvista._version import version_info
from pyvista._warn_external import warn_external

_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.'
                        )
                        warn_external(msg, PyVistaDeprecationWarning)

                    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