|
| 1 | +import functools |
1 | 2 | import inspect |
| 3 | +import os |
2 | 4 | import warnings |
| 5 | +from collections import Counter |
3 | 6 |
|
4 | 7 | from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async |
5 | 8 |
|
| 9 | +import django |
| 10 | + |
6 | 11 |
|
7 | 12 | class RemovedInDjango61Warning(DeprecationWarning): |
8 | 13 | pass |
@@ -83,6 +88,181 @@ def __new__(cls, name, bases, attrs): |
83 | 88 | return new_class |
84 | 89 |
|
85 | 90 |
|
| 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 | + |
86 | 266 | class MiddlewareMixin: |
87 | 267 | sync_capable = True |
88 | 268 | async_capable = True |
|
0 commit comments