summaryrefslogtreecommitdiff
path: root/django/utils
diff options
context:
space:
mode:
authorMike Edmunds <medmunds@gmail.com>2025-07-16 04:49:03 -0700
committerGitHub <noreply@github.com>2025-07-16 08:49:03 -0300
commitf42b89f1bf49a5b89ed852b60f79342320a81c5e (patch)
treef811ffa528b2f9fb3769b5052c53e90b0ac59878 /django/utils
parent10386fac00be55e73279459f00f1959c3ef30a1c (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.py180
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