Last active
February 8, 2025 22:30
-
-
Save medmunds/6761feb129121581d0fb068b3ca14f2f to your computer and use it in GitHub Desktop.
@deprecate_posargs decorator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import functools | |
import inspect | |
import warnings | |
def deprecate_posargs(category): | |
""" | |
Function/method decorator to deprecate some or all positional arguments. | |
The decorated function will map any positional arguments after the `*` | |
to the corresponding keyword arguments, but issue a deprecation warning. | |
Works on both functions and methods. To apply to a class constructor, | |
decorate its __init__() method. | |
Example: to deprecate passing option1 or option2 as posargs, change:: | |
def some_func(request, option1, option2=True): | |
... | |
to:: | |
@deprecate_posargs(RemovedInDjangoNMWarning) | |
def some_func(request, *, option1, option2=True): | |
... | |
Then after the deprecation period, remove the decorator (but keep the `*`). | |
Caution: during the deprecation period, the decorated function must: | |
- Keep the keyword-only parameters in the original (positional) order. | |
- Not add or remove any positional parameters. | |
""" | |
def decorator(func): | |
if isinstance(func, type): | |
raise TypeError( | |
"@deprecate_posargs cannot be applied to a class." | |
" (Apply it to the __init__ method.)" | |
) | |
params = inspect.signature(func).parameters | |
num_by_kind = Counter(param.kind for param in params.values()) | |
if num_by_kind[inspect.Parameter.VAR_POSITIONAL] > 0: | |
raise TypeError( | |
"@deprecate_posargs() cannot be used with variable positional `*args`." | |
) | |
num_positional_params = ( | |
num_by_kind[inspect.Parameter.POSITIONAL_ONLY] | |
+ num_by_kind[inspect.Parameter.POSITIONAL_OR_KEYWORD] | |
) | |
num_keyword_only_params = num_by_kind[inspect.Parameter.KEYWORD_ONLY] | |
if num_keyword_only_params < 1: | |
raise TypeError( | |
"@deprecate_posargs() requires at least one keyword-only parameter" | |
" (after a `*` entry in the parameters list)." | |
) | |
# All keyword-only params can be initialized from deprecated positional args. | |
num_moveable_args = num_keyword_only_params | |
max_positional_args = num_positional_params + num_moveable_args | |
param_names = list(params.keys()) | |
num_bound_params = 1 if param_names[0] in ("self", "cls") else 0 | |
func_name = func.__name__ | |
if func_name == "__init__": | |
# Use the class name in the warning. (The class is not yet available | |
# when this decorator runs, so extract its name from __qualname__.) | |
func_name = func.__qualname__.rsplit(".", 2)[-2] | |
if num_positional_params - num_bound_params <= 0: | |
base_message = "Use of positional arguments is deprecated." | |
else: | |
base_message = "Use of some positional arguments is deprecated." | |
@functools.wraps(func) | |
def wrapper(*args, **kwargs): | |
num_positional_args = len(args) | |
if num_positional_args > num_positional_params: | |
# Excess positional arguments. Move into keywords if appropriate. | |
if num_positional_args > max_positional_args: | |
raise TypeError( | |
f"{func_name}() takes" | |
f" at most {max_positional_args} positional argument(s)" | |
f" (including {num_moveable_args} deprecated)" | |
f" but {num_positional_args} were given" | |
) | |
had_kwargs = bool(kwargs) | |
moved_names = param_names[num_positional_params:num_positional_args] | |
conflicts = set(moved_names) & set(kwargs.keys()) | |
if conflicts: | |
# Report duplicate param names in original parameter order. | |
conflicts_str = ", ".join( | |
f"'{name}'" for name in moved_names if name in conflicts | |
) | |
raise TypeError( | |
f"{func_name}() got both deprecated positional and keyword" | |
f" argument values for {conflicts_str}." | |
) | |
# Move from positional to keyword arguments. | |
moved_kwargs = dict(zip(moved_names, args[num_positional_params:])) | |
args = args[:num_positional_params] | |
kwargs.update(moved_kwargs) | |
# Issue the deprecation warning. Show (only) the newly | |
# keyword-only args in the suggested replacement: | |
# Change to `func_name(..., kwonly1=..., kwonly2=..., ...)` | |
# Include initial "..." if called with other (un-moved) posargs | |
# (excluding `self` or `cls`). Include trailing "..." if called | |
# with other (un-moved) kwargs. | |
replacement_args = [f"{name}=..." for name in moved_names] | |
if len(args) > num_bound_params: | |
replacement_args.insert(0, "...") | |
if had_kwargs: | |
replacement_args.append("...") | |
replacement_args_str = ", ".join(replacement_args) | |
warnings.warn( | |
base_message + f" Change to `{func_name}({replacement_args_str})`.", | |
category, | |
stacklevel=2, | |
) | |
return func(*args, **kwargs) | |
return wrapper | |
return decorator |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import inspect | |
import timeit | |
from textwrap import dedent | |
from django.test import SimpleTestCase | |
from django.utils.deprecation import RemovedAfterNextVersionWarning | |
from .deprecation import deprecate_posargs | |
class DeprecatePosargsTests(SimpleTestCase): | |
def test_all_keyword_only_params(self): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_func(*, a=1, b=2): | |
return a, b | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of positional arguments is deprecated." | |
" Change to `some_func(a=..., b=...)`.", | |
): | |
result = some_func(10, 20) | |
self.assertEqual(result, (10, 20)) | |
def test_some_keyword_only_params(self): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_func(a, *, b=1): | |
return a, b | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of some positional arguments is deprecated." | |
" Change to `some_func(..., b=...)`.", | |
): | |
result = some_func(10, 20) | |
self.assertEqual(result, (10, 20)) | |
def test_no_warning_when_not_needed(self): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_func(a=0, *, b=1): | |
return a, b | |
with self.subTest("All arguments supplied"): | |
with self.assertNoLogs(level="WARNING"): | |
result = some_func(10, b=20) | |
self.assertEqual(result, (10, 20)) | |
with self.subTest("All default arguments"): | |
with self.assertNoLogs(level="WARNING"): | |
result = some_func() | |
self.assertEqual(result, (0, 1)) | |
with self.subTest("Partial arguments supplied"): | |
with self.assertNoLogs(level="WARNING"): | |
result = some_func(10) | |
self.assertEqual(result, (10, 1)) | |
def test_change_to_variations(self): | |
"""The "change to" recommendation reflects how the function is called.""" | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_func(*, a=1, b=2): | |
return a, b | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def other_func(a=1, *, b=2, c=3): | |
return a, b, c | |
with self.subTest("Lists arguments requiring change"): | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Change to `some_func(a=..., b=...)`.", | |
): | |
result = some_func(10, 20) | |
self.assertEqual(result, (10, 20)) | |
with self.subTest("Omits unused arguments"): | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Change to `some_func(a=...)`.", | |
): | |
result = some_func(10) | |
self.assertEqual(result, (10, 2)) | |
with self.subTest("Elides trailing arguments not requiring change"): | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Change to `some_func(a=..., ...)`.", | |
): | |
result = some_func(10, b=20) | |
self.assertEqual(result, (10, 20)) | |
with self.subTest("Elides leading arguments not requiring change"): | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Change to `other_func(..., b=...)`.", | |
): | |
result = other_func(10, 20) | |
self.assertEqual(result, (10, 20, 3)) | |
with self.subTest("Elides leading and trailing arguments not requiring change"): | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Change to `other_func(..., b=..., ...)`.", | |
): | |
result = other_func(10, 20, c=30) | |
self.assertEqual(result, (10, 20, 30)) | |
def test_detects_duplicate_arguments(self): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def func(a, *, b=1, c=2): | |
return a, b, c | |
with self.subTest("One duplicate"): | |
with self.assertRaisesMessage( | |
TypeError, | |
"func() got both deprecated positional" | |
" and keyword argument values for 'b'", | |
): | |
func(0, 10, b=12) | |
with self.subTest("Multiple duplicates"): | |
with self.assertRaisesMessage( | |
TypeError, | |
"func() got both deprecated positional" | |
" and keyword argument values for 'b', 'c'", | |
): | |
func(0, 10, 20, b=12, c=22) | |
def test_detects_extra_positional_arguments(self): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def func(a, *, b=1): | |
return a, b | |
with self.assertRaisesMessage( | |
TypeError, | |
"func() takes at most 2 positional argument(s)" | |
" (including 1 deprecated) but 3 were given", | |
): | |
func(10, 20, 30) | |
def test_variable_kwargs(self): | |
"""Works with **kwargs.""" | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_func(a, *, b=1, **kwargs): | |
return a, b, kwargs | |
with self.subTest("Called with additional kwargs"): | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of some positional arguments is deprecated." | |
" Change to `some_func(..., b=..., ...)`.", | |
): | |
result = some_func(10, 20, c=30) | |
self.assertEqual(result, (10, 20, {"c": 30})) | |
with self.subTest("Called without additional kwargs"): | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of some positional arguments is deprecated." | |
" Change to `some_func(..., b=...)`.", | |
): | |
result = some_func(10, 20) | |
self.assertEqual(result, (10, 20, {})) | |
with self.subTest("Called with too many positional arguments"): | |
# Similar to test_detects_extra_positional_arguments() above, | |
# but verifying logic is not confused by variable **kwargs. | |
with self.assertRaisesMessage( | |
TypeError, | |
"some_func() takes at most 2 positional argument(s)" | |
" (including 1 deprecated) but 3 were given", | |
): | |
some_func(10, 20, 30) | |
with self.subTest("No warning needed"): | |
result = some_func(10, b=20, c=30) | |
self.assertEqual(result, (10, 20, {"c": 30})) | |
def test_positional_only_params(self): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_func(a, /, b, *, c=3): | |
return a, b, c | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of some positional arguments is deprecated." | |
" Change to `some_func(..., c=...)`.", | |
): | |
result = some_func(10, 20, 30) | |
self.assertEqual(result, (10, 20, 30)) | |
def test_class_methods(self): | |
""" | |
Deprecations for class methods should be bound properly and should | |
omit the `self` or `cls` argument from the suggested replacement. | |
""" | |
class SomeClass: | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def __init__(self, *, a=0, b=1): | |
self.a = a | |
self.b = b | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_method(self, *, a, b=1): | |
return self.a, self.b, a, b | |
@staticmethod | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_static_method(*, a, b=1): | |
return a, b | |
@classmethod | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_class_method(cls, *, a, b=1): | |
return cls.__name__, a, b | |
with self.subTest("Constructor"): | |
# Warning should use the class name, not `__init__()`. | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of positional arguments is deprecated." | |
" Change to `SomeClass(a=..., b=...)`.", | |
): | |
instance = SomeClass(10, 20) | |
self.assertEqual(instance.a, 10) | |
self.assertEqual(instance.b, 20) | |
with self.subTest("Instance method"): | |
instance = SomeClass() | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of positional arguments is deprecated." | |
" Change to `some_method(a=..., b=...)`.", | |
): | |
result = instance.some_method(10, 20) | |
self.assertEqual(result, (0, 1, 10, 20)) | |
with self.subTest("Static method"): | |
instance = SomeClass() | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of positional arguments is deprecated." | |
" Change to `some_static_method(a=..., b=...)`.", | |
): | |
result = instance.some_static_method(10, 20) | |
self.assertEqual(result, (10, 20)) | |
with self.subTest("Class method"): | |
instance = SomeClass() | |
with self.assertWarnsMessage( | |
RemovedAfterNextVersionWarning, | |
"Use of positional arguments is deprecated." | |
" Change to `some_class_method(a=..., b=...)`.", | |
): | |
result = instance.some_class_method(10, 20) | |
self.assertEqual(result, ("SomeClass", 10, 20)) | |
def test_warning_stacklevel(self): | |
"""The warning points to caller, not the decorator implementation.""" | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def some_func(*, a): | |
return a | |
with self.assertWarns(RemovedAfterNextVersionWarning) as cm: | |
some_func(10) | |
self.assertEqual(cm.filename, __file__) | |
self.assertEqual(cm.lineno, inspect.currentframe().f_lineno - 2) | |
def test_decorator_requires_keyword_only_params(self): | |
with self.assertRaisesMessage( | |
TypeError, | |
"@deprecate_posargs() requires at least one keyword-only parameter" | |
" (after a `*` entry in the parameters list).", | |
): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def func(a, b=1): | |
return a, b | |
def test_decorator_rejects_var_positional_param(self): | |
with self.assertRaisesMessage( | |
TypeError, | |
"@deprecate_posargs() cannot be used with variable positional `*args`.", | |
): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
def func(*args, b=1): | |
return args, b | |
def test_decorator_does_not_apply_to_class(self): | |
with self.assertRaisesMessage( | |
TypeError, | |
"@deprecate_posargs cannot be applied to a class." | |
" (Apply it to the __init__ method.)", | |
): | |
@deprecate_posargs(RemovedAfterNextVersionWarning) | |
class NotThisClass: | |
pass | |
def test_decorator_preserves_signature(self): | |
""" | |
The decorated function has the same signature as the original. | |
This may be important for certain coding tools (e.g., IDE autocompletion). | |
""" | |
def original(a, b=1, *, c=2): | |
return a, b, c | |
decorated = deprecate_posargs(RemovedAfterNextVersionWarning)(original) | |
self.assertEqual(inspect.signature(original), inspect.signature(decorated)) | |
# TODO: remove or skip this test. | |
# @unittest.skip("Quite slow, and not particularly informative.") | |
def test_overhead(self): | |
""" | |
The decorator's overhead should be reasonable for non-deprecated calls. | |
""" | |
# Allow a function _that does nothing_ to be this many times slower when | |
# decorated, compared to without @decorate_posargs. Although 10x seems high, | |
# this is measuring purely the overhead for the extra stack frame | |
# and the *args/**kwargs packing/unpacking added by the decorator. | |
# In real-world usage (where the decorated function does _something_), | |
# the decorator's relative overhead is unlikely to be significant | |
# unless used on performance-critical code paths. | |
acceptable_overhead_factor = 10 | |
num_runs = 1_000_000 | |
calls_per_run = 4 | |
usec_per_sec = 1_000_000 | |
test = dedent( | |
# Call with a variety of allowable (non-deprecated) arguments. | |
"""\ | |
some_func(1, 2, c=3) | |
some_func(1, b=2, c=3) | |
some_func(1, c=3) | |
some_func(1) | |
""" | |
) | |
def original(a, b=1, *, c=2): | |
pass | |
original_times = timeit.repeat( | |
test, number=num_runs, globals={"some_func": original} | |
) | |
original_time = min(original_times) | |
decorated = deprecate_posargs(RemovedAfterNextVersionWarning)(original) | |
decorated_times = timeit.repeat( | |
test, number=num_runs, globals={"some_func": decorated} | |
) | |
decorated_time = min(decorated_times) | |
overhead = ( # µsec/call | |
(decorated_time - original_time) * usec_per_sec / (num_runs * calls_per_run) | |
) | |
factor = decorated_time / original_time | |
print( | |
f"\n@deprecate_posargs overhead: {factor:.1f}x ({overhead:+.2f} µsec/call)" | |
) | |
self.assertLessEqual(factor, acceptable_overhead_factor) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See also
@deprecate_non_keyword_only_args
from the housekeeping package.