Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/check-commit-messages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Check commit prefix

on:
pull_request:
types: [edited, opened, synchronize, reopened, ready_for_review]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check-commit-prefix:
if: startsWith(github.event.pull_request.base.ref, 'stable/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Calculate commit prefix
id: vars
run: |
BASE="${{ github.event.pull_request.base.ref }}"
HEAD="${{ github.event.pull_request.head.ref }}"
echo "BASE=$BASE" >> $GITHUB_ENV
echo "HEAD=$HEAD" >> $GITHUB_ENV
VERSION="${BASE#stable/}"
echo "prefix=[$VERSION]" >> $GITHUB_OUTPUT

- name: Check PR title prefix
run: |
TITLE="${{ github.event.pull_request.title }}"
PREFIX="${{ steps.vars.outputs.prefix }}"
if [[ "$TITLE" != "$PREFIX"* ]]; then
echo "❌ PR title must start with the required prefix: $PREFIX"
exit 1
fi
echo "✅ PR title has the required prefix."

- name: Fetch base and head branches
run: |
git fetch origin $BASE
git fetch origin $HEAD

- name: Check commit messages prefix
run: |
PREFIX="${{ steps.vars.outputs.prefix }}"
COMMITS=$(git rev-list origin/${BASE}..origin/${HEAD})
echo "Checking commit messages for required prefix: $PREFIX"
FAIL=0
for SHA in $COMMITS; do
MSG=$(git log -1 --pretty=%s $SHA)
echo "Checking commit $SHA: $MSG"
if [[ "$MSG" != "$PREFIX"* ]]; then
echo "❌ Commit $SHA must start with the required prefix: $PREFIX"
FAIL=1
fi
done

if [[ $FAIL -eq 1 ]]; then
echo "One or more commit messages are missing the required prefix."
exit 1
fi

echo "✅ All commits have the required prefix."
180 changes: 180 additions & 0 deletions django/utils/deprecation.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -83,6 +88,181 @@ def __new__(cls, name, bases, attrs):
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ Once you have completed these steps, you are finished with the deprecation.
In each :term:`feature release <Feature release>`, all
``RemovedInDjangoXXWarning``\s matching the new version are removed.

The ``django.utils.deprecation`` module provides some helpful deprecation
utilities, such as a ``@deprecate_posargs`` decorator to assist with converting
positional-or-keyword arguments to keyword-only. See the inline documentation
in the module source.

JavaScript contributions
========================

Expand Down
Loading