-
Notifications
You must be signed in to change notification settings - Fork 2.3k
/
deprecation.py
490 lines (429 loc) · 19.3 KB
/
deprecation.py
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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
# This code is part of Qiskit.
#
# (C) Copyright IBM 2017, 2022.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""Deprecation utilities"""
from __future__ import annotations
import functools
import inspect
import warnings
from collections.abc import Callable
from typing import Any, Type
def deprecate_func(
*,
since: str,
additional_msg: str | None = None,
pending: bool = False,
package_name: str = "qiskit",
removal_timeline: str = "no earlier than 3 months after the release date",
is_property: bool = False,
stacklevel: int = 2,
):
"""Decorator to indicate a function has been deprecated.
It should be placed beneath other decorators like `@staticmethod` and property decorators.
When deprecating a class, set this decorator on its `__init__` function.
Args:
since: The version the deprecation started at. If the deprecation is pending, set
the version to when that started; but later, when switching from pending to
deprecated, update ``since`` to the new version.
additional_msg: Put here any additional information, such as what to use instead.
For example, "Instead, use the function ``new_func`` from the module
``<my_module>.<my_submodule>``, which is similar but uses GPU acceleration."
pending: Set to ``True`` if the deprecation is still pending.
package_name: The PyPI package name, e.g. "qiskit-nature".
removal_timeline: How soon can this deprecation be removed? Expects a value
like "no sooner than 6 months after the latest release" or "in release 9.99".
is_property: If the deprecated function is a `@property`, set this to True so that the
generated message correctly describes it as such. (This isn't necessary for
property setters, as their docstring is ignored by Python.)
stacklevel: Stack level passed to :func:`warnings.warn`.
Returns:
Callable: The decorated callable.
"""
def decorator(func):
qualname = func.__qualname__ # For methods, `qualname` includes the class name.
mod_name = func.__module__
# Detect what function type this is.
if is_property:
# `inspect.isdatadescriptor()` doesn't work because you must apply our decorator
# before `@property`, so it looks like the function is a normal method.
deprecated_entity = f"The property ``{mod_name}.{qualname}``"
# To determine if's a method, we use the heuristic of looking for a `.` in the qualname.
# This is because top-level functions will only have the function name. This is not
# perfect, e.g. it incorrectly classifies nested/inner functions, but we don't expect
# those to be deprecated.
#
# We can't use `inspect.ismethod()` because that only works when calling it on an instance
# of the class, rather than the class type itself, i.e. `ismethod(C().foo)` vs
# `ismethod(C.foo)`.
elif "." in qualname:
if func.__name__ == "__init__":
cls_name = qualname[: -len(".__init__")]
deprecated_entity = f"The class ``{mod_name}.{cls_name}``"
else:
deprecated_entity = f"The method ``{mod_name}.{qualname}()``"
else:
deprecated_entity = f"The function ``{mod_name}.{qualname}()``"
msg, category = _write_deprecation_msg(
deprecated_entity=deprecated_entity,
package_name=package_name,
since=since,
pending=pending,
additional_msg=additional_msg,
removal_timeline=removal_timeline,
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(msg, category=category, stacklevel=stacklevel)
return func(*args, **kwargs)
add_deprecation_to_docstring(wrapper, msg, since=since, pending=pending)
return wrapper
return decorator
def deprecate_arg(
name: str,
*,
since: str,
additional_msg: str | None = None,
deprecation_description: str | None = None,
pending: bool = False,
package_name: str = "qiskit",
new_alias: str | None = None,
predicate: Callable[[Any], bool] | None = None,
removal_timeline: str = "no earlier than 3 months after the release date",
):
"""Decorator to indicate an argument has been deprecated in some way.
This decorator may be used multiple times on the same function, once per deprecated argument.
It should be placed beneath other decorators like ``@staticmethod`` and property decorators.
Args:
name: The name of the deprecated argument.
since: The version the deprecation started at. If the deprecation is pending, set
the version to when that started; but later, when switching from pending to
deprecated, update `since` to the new version.
deprecation_description: What is being deprecated? E.g. "Setting my_func()'s `my_arg`
argument to `None`." If not set, will default to "{func_name}'s argument `{name}`".
additional_msg: Put here any additional information, such as what to use instead
(if new_alias is not set). For example, "Instead, use the argument `new_arg`,
which is similar but does not impact the circuit's setup."
pending: Set to `True` if the deprecation is still pending.
package_name: The PyPI package name, e.g. "qiskit-nature".
new_alias: If the arg has simply been renamed, set this to the new name. The decorator will
dynamically update the `kwargs` so that when the user sets the old arg, it will be
passed in as the `new_alias` arg.
predicate: Only log the runtime warning if the predicate returns True. This is useful to
deprecate certain values or types for an argument, e.g.
`lambda my_arg: isinstance(my_arg, dict)`. Regardless of if a predicate is set, the
runtime warning will only log when the user specifies the argument.
removal_timeline: How soon can this deprecation be removed? Expects a value
like "no sooner than 6 months after the latest release" or "in release 9.99".
Returns:
Callable: The decorated callable.
"""
def decorator(func):
# For methods, `__qualname__` includes the class name.
func_name = f"{func.__module__}.{func.__qualname__}()"
deprecated_entity = deprecation_description or f"``{func_name}``'s argument ``{name}``"
if new_alias:
alias_msg = f"Instead, use the argument ``{new_alias}``, which behaves identically."
if additional_msg:
final_additional_msg = f"{alias_msg}. {additional_msg}"
else:
final_additional_msg = alias_msg
else:
final_additional_msg = additional_msg
msg, category = _write_deprecation_msg(
deprecated_entity=deprecated_entity,
package_name=package_name,
since=since,
pending=pending,
additional_msg=final_additional_msg,
removal_timeline=removal_timeline,
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
_maybe_warn_and_rename_kwarg(
args,
kwargs,
func_name=func_name,
original_func_co_varnames=wrapper.__original_func_co_varnames,
old_arg_name=name,
new_alias=new_alias,
warning_msg=msg,
category=category,
predicate=predicate,
)
return func(*args, **kwargs)
# When decorators get called repeatedly, `func` refers to the result of the prior
# decorator, not the original underlying function. This trick allows us to record the
# original function's variable names regardless of how many decorators are used.
#
# If it's the very first decorator call, we also check that *args and **kwargs are not used.
if hasattr(func, "__original_func_co_varnames"):
wrapper.__original_func_co_varnames = func.__original_func_co_varnames
else:
wrapper.__original_func_co_varnames = func.__code__.co_varnames
param_kinds = {param.kind for param in inspect.signature(func).parameters.values()}
if inspect.Parameter.VAR_POSITIONAL in param_kinds:
raise ValueError(
"@deprecate_arg cannot be used with functions that take variable *args. Use "
"warnings.warn() directly instead."
)
add_deprecation_to_docstring(wrapper, msg, since=since, pending=pending)
return wrapper
return decorator
def deprecate_arguments(
kwarg_map: dict[str, str | None],
category: Type[Warning] = DeprecationWarning,
*,
since: str | None = None,
):
"""Deprecated. Instead, use `@deprecate_arg`.
Args:
kwarg_map: A dictionary of the old argument name to the new name.
category: Usually either DeprecationWarning or PendingDeprecationWarning.
since: The version the deprecation started at. Only Optional for backwards
compatibility - this should always be set. If the deprecation is pending, set
the version to when that started; but later, when switching from pending to
deprecated, update `since` to the new version.
Returns:
Callable: The decorated callable.
"""
def decorator(func):
func_name = func.__qualname__
old_kwarg_to_msg = {}
for old_arg, new_arg in kwarg_map.items():
msg_suffix = (
"will in the future be removed." if new_arg is None else f"replaced with {new_arg}."
)
old_kwarg_to_msg[old_arg] = (
f"{func_name} keyword argument {old_arg} is deprecated and {msg_suffix}"
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for old, new in kwarg_map.items():
_maybe_warn_and_rename_kwarg(
args,
kwargs,
func_name=func_name,
original_func_co_varnames=wrapper.__original_func_co_varnames,
old_arg_name=old,
new_alias=new,
warning_msg=old_kwarg_to_msg[old],
category=category,
predicate=None,
)
return func(*args, **kwargs)
# When decorators get called repeatedly, `func` refers to the result of the prior
# decorator, not the original underlying function. This trick allows us to record the
# original function's variable names regardless of how many decorators are used.
#
# If it's the very first decorator call, we also check that *args and **kwargs are not used.
if hasattr(func, "__original_func_co_varnames"):
wrapper.__original_func_co_varnames = func.__original_func_co_varnames
else:
wrapper.__original_func_co_varnames = func.__code__.co_varnames
param_kinds = {param.kind for param in inspect.signature(func).parameters.values()}
if inspect.Parameter.VAR_POSITIONAL in param_kinds:
raise ValueError(
"@deprecate_arg cannot be used with functions that take variable *args. Use "
"warnings.warn() directly instead."
)
for msg in old_kwarg_to_msg.values():
add_deprecation_to_docstring(
wrapper, msg, since=since, pending=issubclass(category, PendingDeprecationWarning)
)
return wrapper
return decorator
def deprecate_function(
msg: str,
stacklevel: int = 2,
category: Type[Warning] = DeprecationWarning,
*,
since: str | None = None,
):
"""Deprecated. Instead, use `@deprecate_func`.
Args:
msg: Warning message to emit.
stacklevel: The warning stacklevel to use, defaults to 2.
category: Usually either DeprecationWarning or PendingDeprecationWarning.
since: The version the deprecation started at. Only Optional for backwards
compatibility - this should always be set. If the deprecation is pending, set
the version to when that started; but later, when switching from pending to
deprecated, update `since` to the new version.
Returns:
Callable: The decorated, deprecated callable.
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(msg, category=category, stacklevel=stacklevel)
return func(*args, **kwargs)
add_deprecation_to_docstring(
wrapper, msg, since=since, pending=issubclass(category, PendingDeprecationWarning)
)
return wrapper
return decorator
def _maybe_warn_and_rename_kwarg(
args: tuple[Any, ...],
kwargs: dict[str, Any],
*,
func_name: str,
original_func_co_varnames: tuple[str, ...],
old_arg_name: str,
new_alias: str | None,
warning_msg: str,
category: Type[Warning],
predicate: Callable[[Any], bool] | None,
) -> None:
# In Python 3.10+, we should set `zip(strict=False)` (the default). That is, we want to
# stop iterating once `args` is done, since some args may have not been explicitly passed as
# positional args.
arg_names_to_values = {name: val for val, name in zip(args, original_func_co_varnames)}
arg_names_to_values.update(kwargs)
if old_arg_name not in arg_names_to_values:
return
if new_alias and new_alias in arg_names_to_values:
raise TypeError(f"{func_name} received both {new_alias} and {old_arg_name} (deprecated).")
val = arg_names_to_values[old_arg_name]
if predicate and not predicate(val):
return
warnings.warn(warning_msg, category=category, stacklevel=3)
# Finally, if there's a new_alias, add its value dynamically to kwargs so that the code author
# only has to deal with the new_alias in their logic.
if new_alias is not None:
kwargs[new_alias] = val
def _write_deprecation_msg(
*,
deprecated_entity: str,
package_name: str,
since: str,
pending: bool,
additional_msg: str,
removal_timeline: str,
) -> tuple[str, Type[DeprecationWarning] | Type[PendingDeprecationWarning]]:
if pending:
category: Type[DeprecationWarning] | Type[PendingDeprecationWarning] = (
PendingDeprecationWarning
)
deprecation_status = "pending deprecation"
removal_desc = f"marked deprecated in a future release, and then removed {removal_timeline}"
else:
category = DeprecationWarning
deprecation_status = "deprecated"
removal_desc = f"removed {removal_timeline}"
msg = (
f"{deprecated_entity} is {deprecation_status} as of {package_name} {since}. "
f"It will be {removal_desc}."
)
if additional_msg:
msg += f" {additional_msg}"
return msg, category
# We insert deprecations in-between the description and Napoleon's meta sections. The below is from
# https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#docstring-sections. We use
# lowercase because Napoleon is case-insensitive.
_NAPOLEON_META_LINES = frozenset(
{
"args:",
"arguments:",
"attention:",
"attributes:",
"caution:",
"danger:",
"error:",
"example:",
"examples:",
"hint:",
"important:",
"keyword args:",
"keyword arguments:",
"note:",
"notes:",
"other parameters:",
"parameters:",
"return:",
"returns:",
"raises:",
"references:",
"see also:",
"tip:",
"todo:",
"warning:",
"warnings:",
"warn:",
"warns:",
"yield:",
"yields:",
}
)
def add_deprecation_to_docstring(
func: Callable, msg: str, *, since: str | None, pending: bool
) -> None:
"""Dynamically insert the deprecation message into ``func``'s docstring.
Args:
func: The function to modify.
msg: The full deprecation message.
since: The version the deprecation started at.
pending: Is the deprecation still pending?
"""
if "\n" in msg:
raise ValueError(
"Deprecation messages cannot contain new lines (`\\n`), but the deprecation for "
f'{func.__qualname__} had them. Usually this happens when using `"""` multiline '
f"strings; instead, use string concatenation.\n\n"
"This is a simplification to facilitate deprecation messages being added to the "
"documentation. If you have a compelling reason to need "
"new lines, feel free to improve this function or open a request at "
"https://github.com/Qiskit/qiskit/issues."
)
if since is None:
version_str = "unknown"
else:
version_str = f"{since}_pending" if pending else since
indent = ""
meta_index = None
if func.__doc__:
original_lines = func.__doc__.splitlines()
content_encountered = False
for i, line in enumerate(original_lines):
stripped = line.strip()
# Determine the indent based on the first line with content. But, we don't consider the
# first line, which corresponds to the format """Docstring.""", as it does not properly
# capture the indentation of lines beneath it.
if not content_encountered and i != 0 and stripped:
num_leading_spaces = len(line) - len(line.lstrip())
indent = " " * num_leading_spaces
content_encountered = True
if stripped.lower() in _NAPOLEON_META_LINES:
meta_index = i
if not content_encountered:
raise ValueError(
"add_deprecation_to_docstring cannot currently handle when a Napoleon "
"metadata line like 'Args' is the very first line of docstring, "
f'e.g. `"""Args:`. So, it cannot process {func.__qualname__}. Instead, '
f'move the metadata line to the second line, e.g.:\n\n"""\nArgs:'
)
# We can stop checking since we only care about the first meta line, and
# we've validated content_encountered is True to determine the indent.
break
else:
original_lines = []
# We defensively include new lines in the beginning and end. This is sometimes necessary,
# depending on the original docstring. It is not a big deal to have extra, other than `help()`
# being a little longer.
new_lines = [
indent,
f"{indent}.. deprecated:: {version_str}",
f"{indent} {msg}",
indent,
]
if meta_index:
original_lines[meta_index - 1 : meta_index - 1] = new_lines
else:
original_lines.extend(new_lines)
func.__doc__ = "\n".join(original_lines)