Skip to content

Commit f42b89f

Browse files
authored
Fixed #36477, Refs #36163 -- Added @deprecate_posargs decorator to simplify deprecation of positional arguments.
This helper allows marking positional-or-keyword parameters as keyword-only with a deprecation period, in a consistent and correct manner.
1 parent 10386fa commit f42b89f

3 files changed

Lines changed: 589 additions & 0 deletions

File tree

django/utils/deprecation.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import functools
12
import inspect
3+
import os
24
import warnings
5+
from collections import Counter
36

47
from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async
58

9+
import django
10+
611

712
class RemovedInDjango61Warning(DeprecationWarning):
813
pass
@@ -83,6 +88,181 @@ def __new__(cls, name, bases, attrs):
8388
return new_class
8489

8590

91+
def deprecate_posargs(deprecation_warning, remappable_names, /):
92+
"""
93+
Function/method decorator to deprecate some or all positional arguments.
94+
95+
The decorated function will map any positional arguments after the ``*`` to
96+
the corresponding keyword arguments and issue a deprecation warning.
97+
98+
The decorator takes two arguments: a RemovedInDjangoXXWarning warning
99+
category and a list of parameter names that have been changed from
100+
positional-or-keyword to keyword-only, in their original positional order.
101+
102+
Works on both functions and methods. To apply to a class constructor,
103+
decorate its __init__() method. To apply to a staticmethod or classmethod,
104+
use @deprecate_posargs after @staticmethod or @classmethod.
105+
106+
Example: to deprecate passing option1 or option2 as posargs, change::
107+
108+
def some_func(request, option1, option2=True):
109+
...
110+
111+
to::
112+
113+
@deprecate_posargs(RemovedInDjangoXXWarning, ["option1", "option2"])
114+
def some_func(request, *, option1, option2=True):
115+
...
116+
117+
After the deprecation period, remove the decorator (but keep the ``*``)::
118+
119+
def some_func(request, *, option1, option2=True):
120+
...
121+
122+
Caution: during the deprecation period, do not add any new *positional*
123+
parameters or change the remaining ones. For example, this attempt to add a
124+
new param would break code using the deprecated posargs::
125+
126+
@deprecate_posargs(RemovedInDjangoXXWarning, ["option1", "option2"])
127+
def some_func(request, wrong_new_param=None, *, option1, option2=True):
128+
# Broken: existing code may pass a value intended as option1 in the
129+
# wrong_new_param position.
130+
...
131+
132+
However, it's acceptable to add new *keyword-only* parameters and to
133+
re-order the existing ones, so long as the list passed to
134+
@deprecate_posargs is kept in the original posargs order. This change will
135+
work without breaking existing code::
136+
137+
@deprecate_posargs(RemovedInDjangoXXWarning, ["option1", "option2"])
138+
def some_func(request, *, new_param=None, option2=True, option1):
139+
...
140+
141+
The @deprecate_posargs decorator adds a small amount of overhead. In most
142+
cases it won't be significant, but use with care in performance-critical
143+
code paths.
144+
"""
145+
146+
def decorator(func):
147+
if isinstance(func, type):
148+
raise TypeError(
149+
"@deprecate_posargs cannot be applied to a class. (Apply it "
150+
"to the __init__ method.)"
151+
)
152+
if isinstance(func, classmethod):
153+
raise TypeError("Apply @classmethod before @deprecate_posargs.")
154+
if isinstance(func, staticmethod):
155+
raise TypeError("Apply @staticmethod before @deprecate_posargs.")
156+
157+
params = inspect.signature(func).parameters
158+
num_by_kind = Counter(param.kind for param in params.values())
159+
160+
if num_by_kind[inspect.Parameter.VAR_POSITIONAL] > 0:
161+
raise TypeError(
162+
"@deprecate_posargs() cannot be used with variable positional `*args`."
163+
)
164+
165+
num_positional_params = (
166+
num_by_kind[inspect.Parameter.POSITIONAL_ONLY]
167+
+ num_by_kind[inspect.Parameter.POSITIONAL_OR_KEYWORD]
168+
)
169+
num_keyword_only_params = num_by_kind[inspect.Parameter.KEYWORD_ONLY]
170+
if num_keyword_only_params < 1:
171+
raise TypeError(
172+
"@deprecate_posargs() requires at least one keyword-only parameter "
173+
"(after a `*` entry in the parameters list)."
174+
)
175+
if any(
176+
name not in params or params[name].kind != inspect.Parameter.KEYWORD_ONLY
177+
for name in remappable_names
178+
):
179+
raise TypeError(
180+
"@deprecate_posargs() requires all remappable_names to be "
181+
"keyword-only parameters."
182+
)
183+
184+
num_remappable_args = len(remappable_names)
185+
max_positional_args = num_positional_params + num_remappable_args
186+
187+
func_name = func.__name__
188+
if func_name == "__init__":
189+
# In the warning, show "ClassName()" instead of "__init__()".
190+
# The class isn't defined yet, but its name is in __qualname__.
191+
# Some examples of __qualname__:
192+
# - ClassName.__init__
193+
# - Nested.ClassName.__init__
194+
# - MyTests.test_case.<locals>.ClassName.__init__
195+
local_name = func.__qualname__.rsplit("<locals>.", 1)[-1]
196+
class_name = local_name.replace(".__init__", "")
197+
func_name = class_name
198+
199+
def remap_deprecated_args(args, kwargs):
200+
"""
201+
Move deprecated positional args to kwargs and issue a warning.
202+
Return updated (args, kwargs).
203+
"""
204+
if (num_positional_args := len(args)) > max_positional_args:
205+
raise TypeError(
206+
f"{func_name}() takes at most {max_positional_args} positional "
207+
f"argument(s) (including {num_remappable_args} deprecated) but "
208+
f"{num_positional_args} were given."
209+
)
210+
211+
# Identify which of the _potentially remappable_ params are
212+
# actually _being remapped_ in this particular call.
213+
remapped_names = remappable_names[
214+
: num_positional_args - num_positional_params
215+
]
216+
conflicts = set(remapped_names) & set(kwargs)
217+
if conflicts:
218+
# Report duplicate names in the original parameter order.
219+
conflicts_str = ", ".join(
220+
f"'{name}'" for name in remapped_names if name in conflicts
221+
)
222+
raise TypeError(
223+
f"{func_name}() got both deprecated positional and keyword "
224+
f"argument values for {conflicts_str}."
225+
)
226+
227+
# Do the remapping.
228+
remapped_kwargs = dict(
229+
zip(remapped_names, args[num_positional_params:], strict=True)
230+
)
231+
remaining_args = args[:num_positional_params]
232+
updated_kwargs = kwargs | remapped_kwargs
233+
234+
# Issue the deprecation warning.
235+
remapped_names_str = ", ".join(f"'{name}'" for name in remapped_names)
236+
warnings.warn(
237+
f"Passing positional argument(s) {remapped_names_str} to {func_name}() "
238+
"is deprecated. Use keyword arguments instead.",
239+
deprecation_warning,
240+
skip_file_prefixes=(os.path.dirname(django.__file__),),
241+
)
242+
243+
return remaining_args, updated_kwargs
244+
245+
if iscoroutinefunction(func):
246+
247+
@functools.wraps(func)
248+
async def wrapper(*args, **kwargs):
249+
if len(args) > num_positional_params:
250+
args, kwargs = remap_deprecated_args(args, kwargs)
251+
return await func(*args, **kwargs)
252+
253+
else:
254+
255+
@functools.wraps(func)
256+
def wrapper(*args, **kwargs):
257+
if len(args) > num_positional_params:
258+
args, kwargs = remap_deprecated_args(args, kwargs)
259+
return func(*args, **kwargs)
260+
261+
return wrapper
262+
263+
return decorator
264+
265+
86266
class MiddlewareMixin:
87267
sync_capable = True
88268
async_capable = True

docs/internals/contributing/writing-code/submitting-patches.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ Once you have completed these steps, you are finished with the deprecation.
312312
In each :term:`feature release <Feature release>`, all
313313
``RemovedInDjangoXXWarning``\s matching the new version are removed.
314314

315+
The ``django.utils.deprecation`` module provides some helpful deprecation
316+
utilities, such as a ``@deprecate_posargs`` decorator to assist with converting
317+
positional-or-keyword arguments to keyword-only. See the inline documentation
318+
in the module source.
319+
315320
JavaScript contributions
316321
========================
317322

0 commit comments

Comments
 (0)