Skip to content

Instantly share code, notes, and snippets.

@medmunds
Last active February 8, 2025 22:30
Show Gist options
  • Save medmunds/6761feb129121581d0fb068b3ca14f2f to your computer and use it in GitHub Desktop.
Save medmunds/6761feb129121581d0fb068b3ca14f2f to your computer and use it in GitHub Desktop.
@deprecate_posargs decorator
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
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)
@medmunds
Copy link
Author

See also @deprecate_non_keyword_only_args from the housekeeping package.

@medmunds
Copy link
Author

medmunds commented Feb 8, 2025

Improved version: django/django#19145

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment