diff options
| author | Mike Edmunds <medmunds@gmail.com> | 2025-07-16 04:49:03 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-16 08:49:03 -0300 |
| commit | f42b89f1bf49a5b89ed852b60f79342320a81c5e (patch) | |
| tree | f811ffa528b2f9fb3769b5052c53e90b0ac59878 /django/utils | |
| parent | 10386fac00be55e73279459f00f1959c3ef30a1c (diff) | |
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.
Diffstat (limited to 'django/utils')
| -rw-r--r-- | django/utils/deprecation.py | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/django/utils/deprecation.py b/django/utils/deprecation.py index 6f3852cc04..7d5289bd3c 100644 --- a/django/utils/deprecation.py +++ b/django/utils/deprecation.py @@ -1,8 +1,13 @@ +import functools import inspect +import os import warnings +from collections import Counter from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async +import django + class RemovedInDjango61Warning(DeprecationWarning): pass @@ -83,6 +88,181 @@ class RenameMethodsBase(type): return new_class +def deprecate_posargs(deprecation_warning, remappable_names, /): + """ + 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 and issue a deprecation warning. + + The decorator takes two arguments: a RemovedInDjangoXXWarning warning + category and a list of parameter names that have been changed from + positional-or-keyword to keyword-only, in their original positional order. + + Works on both functions and methods. To apply to a class constructor, + decorate its __init__() method. To apply to a staticmethod or classmethod, + use @deprecate_posargs after @staticmethod or @classmethod. + + Example: to deprecate passing option1 or option2 as posargs, change:: + + def some_func(request, option1, option2=True): + ... + + to:: + + @deprecate_posargs(RemovedInDjangoXXWarning, ["option1", "option2"]) + def some_func(request, *, option1, option2=True): + ... + + After the deprecation period, remove the decorator (but keep the ``*``):: + + def some_func(request, *, option1, option2=True): + ... + + Caution: during the deprecation period, do not add any new *positional* + parameters or change the remaining ones. For example, this attempt to add a + new param would break code using the deprecated posargs:: + + @deprecate_posargs(RemovedInDjangoXXWarning, ["option1", "option2"]) + def some_func(request, wrong_new_param=None, *, option1, option2=True): + # Broken: existing code may pass a value intended as option1 in the + # wrong_new_param position. + ... + + However, it's acceptable to add new *keyword-only* parameters and to + re-order the existing ones, so long as the list passed to + @deprecate_posargs is kept in the original posargs order. This change will + work without breaking existing code:: + + @deprecate_posargs(RemovedInDjangoXXWarning, ["option1", "option2"]) + def some_func(request, *, new_param=None, option2=True, option1): + ... + + The @deprecate_posargs decorator adds a small amount of overhead. In most + cases it won't be significant, but use with care in performance-critical + code paths. + """ + + def decorator(func): + if isinstance(func, type): + raise TypeError( + "@deprecate_posargs cannot be applied to a class. (Apply it " + "to the __init__ method.)" + ) + if isinstance(func, classmethod): + raise TypeError("Apply @classmethod before @deprecate_posargs.") + if isinstance(func, staticmethod): + raise TypeError("Apply @staticmethod before @deprecate_posargs.") + + 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)." + ) + if any( + name not in params or params[name].kind != inspect.Parameter.KEYWORD_ONLY + for name in remappable_names + ): + raise TypeError( + "@deprecate_posargs() requires all remappable_names to be " + "keyword-only parameters." + ) + + num_remappable_args = len(remappable_names) + max_positional_args = num_positional_params + num_remappable_args + + func_name = func.__name__ + if func_name == "__init__": + # In the warning, show "ClassName()" instead of "__init__()". + # The class isn't defined yet, but its name is in __qualname__. + # Some examples of __qualname__: + # - ClassName.__init__ + # - Nested.ClassName.__init__ + # - MyTests.test_case.<locals>.ClassName.__init__ + local_name = func.__qualname__.rsplit("<locals>.", 1)[-1] + class_name = local_name.replace(".__init__", "") + func_name = class_name + + def remap_deprecated_args(args, kwargs): + """ + Move deprecated positional args to kwargs and issue a warning. + Return updated (args, kwargs). + """ + if (num_positional_args := len(args)) > max_positional_args: + raise TypeError( + f"{func_name}() takes at most {max_positional_args} positional " + f"argument(s) (including {num_remappable_args} deprecated) but " + f"{num_positional_args} were given." + ) + + # Identify which of the _potentially remappable_ params are + # actually _being remapped_ in this particular call. + remapped_names = remappable_names[ + : num_positional_args - num_positional_params + ] + conflicts = set(remapped_names) & set(kwargs) + if conflicts: + # Report duplicate names in the original parameter order. + conflicts_str = ", ".join( + f"'{name}'" for name in remapped_names if name in conflicts + ) + raise TypeError( + f"{func_name}() got both deprecated positional and keyword " + f"argument values for {conflicts_str}." + ) + + # Do the remapping. + remapped_kwargs = dict( + zip(remapped_names, args[num_positional_params:], strict=True) + ) + remaining_args = args[:num_positional_params] + updated_kwargs = kwargs | remapped_kwargs + + # Issue the deprecation warning. + remapped_names_str = ", ".join(f"'{name}'" for name in remapped_names) + warnings.warn( + f"Passing positional argument(s) {remapped_names_str} to {func_name}() " + "is deprecated. Use keyword arguments instead.", + deprecation_warning, + skip_file_prefixes=(os.path.dirname(django.__file__),), + ) + + return remaining_args, updated_kwargs + + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + if len(args) > num_positional_params: + args, kwargs = remap_deprecated_args(args, kwargs) + return await func(*args, **kwargs) + + else: + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if len(args) > num_positional_params: + args, kwargs = remap_deprecated_args(args, kwargs) + return func(*args, **kwargs) + + return wrapper + + return decorator + + class MiddlewareMixin: sync_capable = True async_capable = True |
