From 3c09ed81d3e90d7ce60372096c58e80548d1d2ef Mon Sep 17 00:00:00 2001
From: Jacob Walls
Date: Mon, 9 Feb 2026 18:27:23 -0500
Subject: [PATCH 01/11] Refs #35444 -- Doc'd deprecation in
contrib.postgres.aggreggates.StringAgg.delimiter.
---
docs/ref/contrib/postgres/aggregates.txt | 13 ++++++++++++-
docs/releases/6.0.txt | 7 +++++++
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/docs/ref/contrib/postgres/aggregates.txt b/docs/ref/contrib/postgres/aggregates.txt
index 0ca144bb6623..040e57c5af0c 100644
--- a/docs/ref/contrib/postgres/aggregates.txt
+++ b/docs/ref/contrib/postgres/aggregates.txt
@@ -190,7 +190,18 @@ General-purpose aggregation functions
.. attribute:: delimiter
- Required argument. Needs to be a string.
+ Required argument. A string, :class:`~django.db.models.Value`, or
+ expression representing the string for separating values. For example,
+ ``Value(",")``.
+
+ .. versionadded:: 6.0
+
+ Support for providing a ``Value`` or expression rather than a
+ string was added.
+
+ .. deprecated:: 6.0
+
+ Support for providing a string is deprecated.
.. attribute:: distinct
diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt
index ff810b03542d..bfad64e48580 100644
--- a/docs/releases/6.0.txt
+++ b/docs/releases/6.0.txt
@@ -517,6 +517,13 @@ Miscellaneous
* The PostgreSQL ``StringAgg`` class is deprecated in favor of the generally
available :class:`~django.db.models.StringAgg` class.
+* Passing a string to the
+ :attr:`~django.contrib.postgres.aggregates.StringAgg.delimiter` argument of
+ the (deprecated) PostgreSQL ``StringAgg`` class is deprecated. Use a
+ :class:`~django.db.models.Value` or expression instead to prepare for
+ compatibility with the generally available
+ :class:`~django.db.models.StringAgg` class.
+
* The PostgreSQL ``OrderableAggMixin`` is deprecated in favor of the
``order_by`` attribute now available on the
:class:`~django.db.models.Aggregate` class.
From 0dac3dd4a1573b3c9cef3aea6a98440decfc5460 Mon Sep 17 00:00:00 2001
From: Jacob Walls
Date: Mon, 9 Feb 2026 10:29:26 -0500
Subject: [PATCH 02/11] Clarified optional nature of Contributor License
Agreement.
It's not clear that CLAs are needed to ensure contributors are
assenting to our license (the "inbound=outbound" agreement),
but we can keep them around for contributors who would like to
(or are required by their employer) to submit one, without
investing additional resources in checking every single contribution.
See https://forum.djangoproject.com/t/cla-vs-dco-for-django-contributors/42399
and recent board minutes.
---
docs/internals/contributing/new-contributors.txt | 4 ++--
.../contributing/writing-code/submitting-patches.txt | 12 ++++++------
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/docs/internals/contributing/new-contributors.txt b/docs/internals/contributing/new-contributors.txt
index 63835289ebb5..bf80dee36589 100644
--- a/docs/internals/contributing/new-contributors.txt
+++ b/docs/internals/contributing/new-contributors.txt
@@ -69,8 +69,8 @@ Sign the Contributor License Agreement
--------------------------------------
The code that you write belongs to you or your employer. If your contribution
-is more than one or two lines of code, you need to sign the `CLA`_. See the
-`Contributor License Agreement FAQ`_ for a more thorough explanation.
+is more than one or two lines of code, you have the option to sign the `CLA`_.
+See the `Contributor License Agreement FAQ`_ for a more thorough explanation.
.. _CLA: https://www.djangoproject.com/foundation/cla/
.. _Contributor License Agreement FAQ: https://www.djangoproject.com/foundation/cla/faq/
diff --git a/docs/internals/contributing/writing-code/submitting-patches.txt b/docs/internals/contributing/writing-code/submitting-patches.txt
index 9c1d417031a2..6900045cc05d 100644
--- a/docs/internals/contributing/writing-code/submitting-patches.txt
+++ b/docs/internals/contributing/writing-code/submitting-patches.txt
@@ -59,11 +59,10 @@ and time availability), claim it by following these steps:
* Finally click the "Submit changes" button at the bottom to save.
.. note::
- The Django software foundation requests that anyone contributing more than
- a :ref:`trivial change `, to Django sign and submit a
- `Contributor License Agreement`_, this ensures that the Django Software
- Foundation has clear license to all contributions allowing for a clear
- license for all users.
+ If your change is not :ref:`trivial `, you have the option
+ to sign and submit a `Contributor License Agreement`_ clarifying the status
+ of your contribution. This ensures that the Django Software Foundation has
+ clear license to your contribution.
.. _Login using your GitHub account: https://code.djangoproject.com/github/login
.. _Create an account: https://www.djangoproject.com/accounts/register/
@@ -508,7 +507,8 @@ All tickets
* Is the pull request a single squashed commit with a message that follows our
:ref:`commit message format `?
* Are you the patch author and a new contributor? Please add yourself to the
- :source:`AUTHORS` file and submit a `Contributor License Agreement`_.
+ :source:`AUTHORS` file. At your option, submit a
+ `Contributor License Agreement`_.
* Does this have an accepted ticket on Trac? All contributions require a ticket
unless the :ref:`change is considered trivial `.
From 64a4d8ad005721b17fafd3399bdf49d7d0f94455 Mon Sep 17 00:00:00 2001
From: Hossam Hassan
Date: Tue, 10 Feb 2026 18:56:29 +0200
Subject: [PATCH 03/11] Fixed #34352 -- Unified terms in Signals docs.
---
docs/topics/signals.txt | 30 +++++++++++++++---------------
1 file changed, 15 insertions(+), 15 deletions(-)
diff --git a/docs/topics/signals.txt b/docs/topics/signals.txt
index 8f2246c55a30..c14def278b47 100644
--- a/docs/topics/signals.txt
+++ b/docs/topics/signals.txt
@@ -18,7 +18,7 @@ changes::
from django.core.signals import setting_changed
- def my_callback(sender, **kwargs):
+ def my_receiver(sender, **kwargs):
print("Setting changed!")
@@ -26,7 +26,7 @@ changes::
...
def ready(self):
- setting_changed.connect(my_callback)
+ setting_changed.connect(my_receiver)
Django's :doc:`built-in signals ` let user code get notified of
certain actions.
@@ -58,7 +58,7 @@ the order they were registered.
:param sender: Specifies a particular sender to receive signals from. See
:ref:`connecting-to-specific-signals` for more information.
- :param weak: Django stores signal handlers as weak references by
+ :param weak: Django stores signal receivers as weak references by
default. Thus, if your receiver is a local function, it may be
garbage collected. To prevent this, pass ``weak=False`` when you call
the signal's ``connect()`` method.
@@ -79,11 +79,11 @@ Receiver functions
First, we need to define a receiver function. A receiver can be any Python
function or method::
- def my_callback(sender, **kwargs):
+ def my_receiver(sender, **kwargs):
print("Request finished!")
Notice that the function takes a ``sender`` argument, along with wildcard
-keyword arguments (``**kwargs``); all signal handlers must take these
+keyword arguments (``**kwargs``); all signal receivers must take these
arguments.
We'll look at senders :ref:`a bit later `, but
@@ -91,7 +91,7 @@ right now look at the ``**kwargs`` argument. All signals send keyword
arguments, and may change those keyword arguments at any time. In the case of
:data:`~django.core.signals.request_finished`, it's documented as sending no
arguments, which means we might be tempted to write our signal handling as
-``my_callback(sender)``.
+``my_receiver(sender)``.
This would be wrong -- in fact, Django will throw an error if you do so. That's
because at any point arguments could get added to the signal and your receiver
@@ -100,7 +100,7 @@ must be able to handle those new arguments.
Receivers may also be asynchronous functions, with the same signature but
declared using ``async def``::
- async def my_callback(sender, **kwargs):
+ async def my_receiver(sender, **kwargs):
await asyncio.sleep(5)
print("Request finished!")
@@ -118,7 +118,7 @@ manual connect route::
from django.core.signals import request_finished
- request_finished.connect(my_callback)
+ request_finished.connect(my_receiver)
Alternatively, you can use a :func:`receiver` decorator:
@@ -135,10 +135,10 @@ Here's how you connect with the decorator::
@receiver(request_finished)
- def my_callback(sender, **kwargs):
+ def my_receiver(sender, **kwargs):
print("Request finished!")
-Now, our ``my_callback`` function will be called each time a request finishes.
+Now, our ``my_receiver`` function will be called each time a request finishes.
.. admonition:: Where should this code live?
@@ -146,13 +146,13 @@ Now, our ``my_callback`` function will be called each time a request finishes.
you like, although it's recommended to avoid the application's root module
and its ``models`` module to minimize side-effects of importing code.
- In practice, signal handlers are usually defined in a ``signals``
+ In practice, signal receivers are usually defined in a ``signals``
submodule of the application they relate to. Signal receivers are
connected in the :meth:`~django.apps.AppConfig.ready` method of your
application :ref:`configuration class `. If
you're using the :func:`receiver` decorator, import the ``signals``
submodule inside :meth:`~django.apps.AppConfig.ready`, this will implicitly
- connect signal handlers::
+ connect signal receivers::
from django.apps import AppConfig
from django.core.signals import request_finished
@@ -162,11 +162,11 @@ Now, our ``my_callback`` function will be called each time a request finishes.
...
def ready(self):
- # Implicitly connect signal handlers decorated with @receiver.
+ # Implicitly connect signal receivers decorated with @receiver.
from . import signals
# Explicitly connect a signal handler.
- request_finished.connect(signals.my_callback)
+ request_finished.connect(signals.my_receiver)
.. note::
@@ -228,7 +228,7 @@ bound to the signal once for each unique ``dispatch_uid`` value::
from django.core.signals import request_finished
- request_finished.connect(my_callback, dispatch_uid="my_unique_identifier")
+ request_finished.connect(my_receiver, dispatch_uid="my_unique_identifier")
.. _defining-and-sending-signals:
From d007fcf7291cc3c24d4545e23c759bde22b6a8a6 Mon Sep 17 00:00:00 2001
From: Tim Graham
Date: Fri, 6 Feb 2026 11:06:17 -0500
Subject: [PATCH 04/11] Modified tests to format PKs with %s rather than %d.
It's how Django formats values internally and makes tests compatible
with databases that use non-integer primary keys.
---
tests/admin_changelist/tests.py | 2 +-
tests/admin_filters/tests.py | 8 +--
tests/admin_inlines/tests.py | 12 ++---
tests/admin_utils/test_logentry.py | 2 +-
tests/admin_views/admin.py | 2 +-
tests/admin_views/models.py | 2 +-
tests/admin_views/tests.py | 20 ++++----
tests/auth_tests/test_context_processors.py | 2 +-
tests/auth_tests/test_management.py | 6 +--
tests/file_uploads/views.py | 2 +-
tests/forms_tests/models.py | 2 +-
tests/generic_views/test_dates.py | 8 +--
tests/generic_views/test_edit.py | 56 ++++++++++-----------
tests/many_to_one/tests.py | 2 +-
tests/model_forms/test_modelchoicefield.py | 12 ++---
tests/model_forms/tests.py | 6 +--
tests/model_formsets/tests.py | 38 +++++++-------
tests/modeladmin/tests.py | 6 +--
tests/serializers/test_data.py | 4 +-
tests/serializers/test_natural.py | 2 +-
20 files changed, 97 insertions(+), 97 deletions(-)
diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py
index 330859bcf438..626558f496c8 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -418,7 +418,7 @@ def test_result_list_editable_html(self):
# make sure that hidden fields are in the correct place
hiddenfields_div = (
''
- ' '
+ ' '
"
"
) % new_child.id
self.assertInHTML(
diff --git a/tests/admin_filters/tests.py b/tests/admin_filters/tests.py
index 530d4c53b682..cef03eb452ed 100644
--- a/tests/admin_filters/tests.py
+++ b/tests/admin_filters/tests.py
@@ -700,7 +700,7 @@ def test_relatedfieldlistfilter_foreignkey(self):
choice = select_by(filterspec.choices(changelist), "display", "alfred")
self.assertIs(choice["selected"], True)
self.assertEqual(
- choice["query_string"], "?author__id__exact=%d" % self.alfred.pk
+ choice["query_string"], "?author__id__exact=%s" % self.alfred.pk
)
def test_relatedfieldlistfilter_foreignkey_ordering(self):
@@ -803,7 +803,7 @@ def test_relatedfieldlistfilter_manytomany(self):
choice = select_by(filterspec.choices(changelist), "display", "bob")
self.assertIs(choice["selected"], True)
self.assertEqual(
- choice["query_string"], "?contributors__id__exact=%d" % self.bob.pk
+ choice["query_string"], "?contributors__id__exact=%s" % self.bob.pk
)
def test_relatedfieldlistfilter_reverse_relationships(self):
@@ -839,7 +839,7 @@ def test_relatedfieldlistfilter_reverse_relationships(self):
)
self.assertIs(choice["selected"], True)
self.assertEqual(
- choice["query_string"], "?books_authored__id__exact=%d" % self.bio_book.pk
+ choice["query_string"], "?books_authored__id__exact=%s" % self.bio_book.pk
)
# M2M relationship -----
@@ -873,7 +873,7 @@ def test_relatedfieldlistfilter_reverse_relationships(self):
self.assertIs(choice["selected"], True)
self.assertEqual(
choice["query_string"],
- "?books_contributed__id__exact=%d" % self.django_book.pk,
+ "?books_contributed__id__exact=%s" % self.django_book.pk,
)
# With one book, the list filter should appear because there is also a
diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py
index 4dbaaf8e22a7..6956c37740ed 100644
--- a/tests/admin_inlines/tests.py
+++ b/tests/admin_inlines/tests.py
@@ -1198,7 +1198,7 @@ def test_inline_change_m2m_change_perm(self):
)
self.assertContains(
response,
- ' ' % self.author_book_auto_m2m_intermediate_id,
html=True,
)
@@ -1226,7 +1226,7 @@ def test_inline_change_fk_add_perm(self):
)
self.assertNotContains(
response,
- ' ' % self.inner2.id,
html=True,
)
@@ -1258,7 +1258,7 @@ def test_inline_change_fk_change_perm(self):
)
self.assertContains(
response,
- ' ' % self.inner2.id,
html=True,
)
@@ -1305,7 +1305,7 @@ def test_inline_change_fk_add_change_perm(self):
)
self.assertContains(
response,
- ' ' % self.inner2.id,
html=True,
)
@@ -1335,7 +1335,7 @@ def test_inline_change_fk_change_del_perm(self):
)
self.assertContains(
response,
- ' ' % self.inner2.id,
html=True,
)
@@ -1375,7 +1375,7 @@ def test_inline_change_fk_all_perms(self):
)
self.assertContains(
response,
- ' ' % self.inner2.id,
html=True,
)
diff --git a/tests/admin_utils/test_logentry.py b/tests/admin_utils/test_logentry.py
index 37d13d24bd45..43cc281335a3 100644
--- a/tests/admin_utils/test_logentry.py
+++ b/tests/admin_utils/test_logentry.py
@@ -224,7 +224,7 @@ def test_logentry_get_admin_url(self):
"admin:admin_utils_article_change", args=(quote(self.a1.pk),)
)
self.assertEqual(logentry.get_admin_url(), expected_url)
- self.assertIn("article/%d/change/" % self.a1.pk, logentry.get_admin_url())
+ self.assertIn("article/%s/change/" % self.a1.pk, logentry.get_admin_url())
logentry.content_type.model = "nonexistent"
self.assertIsNone(logentry.get_admin_url())
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index 506f9d475dc2..8e8f7a32cc00 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -617,7 +617,7 @@ class PostAdmin(admin.ModelAdmin):
@admin.display
def coolness(self, instance):
if instance.pk:
- return "%d amount of cool." % instance.pk
+ return "%s amount of cool." % instance.pk
else:
return "Unknown coolness."
diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py
index 2f4e43394a03..5191d1605eb9 100644
--- a/tests/admin_views/models.py
+++ b/tests/admin_views/models.py
@@ -961,7 +961,7 @@ def get_queryset(self):
class FilteredManager(models.Model):
def __str__(self):
- return "PK=%d" % self.pk
+ return "PK=%s" % self.pk
pk_gt_1 = _Manager()
objects = models.Manager()
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 7949622f0ea9..4beca793d61e 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -1300,7 +1300,7 @@ def test_disallowed_filtering(self):
response = self.client.get(reverse("admin:admin_views_workhour_changelist"))
self.assertContains(response, "employee__person_ptr__exact")
response = self.client.get(
- "%s?employee__person_ptr__exact=%d"
+ "%s?employee__person_ptr__exact=%s"
% (reverse("admin:admin_views_workhour_changelist"), e1.pk)
)
self.assertEqual(response.status_code, 200)
@@ -4769,13 +4769,13 @@ def test_pk_hidden_fields(self):
self.assertContains(
response,
'\n'
- ' '
- ' \n'
+ ' '
+ ' \n'
"
" % (story2.id, story1.id),
html=True,
)
- self.assertContains(response, '%d ' % story1.id, 1)
- self.assertContains(response, '%d ' % story2.id, 1)
+ self.assertContains(response, '%s ' % story1.id, 1)
+ self.assertContains(response, '%s ' % story2.id, 1)
def test_pk_hidden_fields_with_list_display_links(self):
"""Similarly as test_pk_hidden_fields, but when the hidden pk fields
@@ -4798,19 +4798,19 @@ def test_pk_hidden_fields_with_list_display_links(self):
self.assertContains(
response,
'\n'
- ' '
- ' \n'
+ ' '
+ ' \n'
"
" % (story2.id, story1.id),
html=True,
)
self.assertContains(
response,
- '%d ' % (link1, story1.id),
+ '%s ' % (link1, story1.id),
1,
)
self.assertContains(
response,
- '%d ' % (link2, story2.id),
+ '%s ' % (link2, story2.id),
1,
)
@@ -7321,7 +7321,7 @@ def test_readonly_get(self):
response = self.client.get(
reverse("admin:admin_views_post_change", args=(p.pk,))
)
- self.assertContains(response, "%d amount of cool" % p.pk)
+ self.assertContains(response, "%s amount of cool" % p.pk)
def test_readonly_text_field(self):
p = Post.objects.create(
diff --git a/tests/auth_tests/test_context_processors.py b/tests/auth_tests/test_context_processors.py
index cebc1108dc8b..85b9e6367b6a 100644
--- a/tests/auth_tests/test_context_processors.py
+++ b/tests/auth_tests/test_context_processors.py
@@ -140,7 +140,7 @@ def test_user_attrs(self):
user = authenticate(username="super", password="secret")
response = self.client.get("/auth_processor_user/")
self.assertContains(response, "unicode: super")
- self.assertContains(response, "id: %d" % self.superuser.pk)
+ self.assertContains(response, "id: %s" % self.superuser.pk)
self.assertContains(response, "username: super")
# bug #12037 is tested by the {% url %} in the template:
self.assertContains(response, "url: /userpage/super/")
diff --git a/tests/auth_tests/test_management.py b/tests/auth_tests/test_management.py
index 0701ac2d68ed..f5027da4e5eb 100644
--- a/tests/auth_tests/test_management.py
+++ b/tests/auth_tests/test_management.py
@@ -607,7 +607,7 @@ def test_validate_fk(self):
email = Email.objects.create(email="mymail@gmail.com")
Group.objects.all().delete()
nonexistent_group_id = 1
- msg = f"group instance with id {nonexistent_group_id} is not a valid choice."
+ msg = f"group instance with id {nonexistent_group_id!r} is not a valid choice."
with self.assertRaisesMessage(CommandError, msg):
call_command(
@@ -624,7 +624,7 @@ def test_validate_fk_environment_variable(self):
email = Email.objects.create(email="mymail@gmail.com")
Group.objects.all().delete()
nonexistent_group_id = 1
- msg = f"group instance with id {nonexistent_group_id} is not a valid choice."
+ msg = f"group instance with id {nonexistent_group_id!r} is not a valid choice."
with mock.patch.dict(
os.environ,
@@ -644,7 +644,7 @@ def test_validate_fk_via_option_interactive(self):
email = Email.objects.create(email="mymail@gmail.com")
Group.objects.all().delete()
nonexistent_group_id = 1
- msg = f"group instance with id {nonexistent_group_id} is not a valid choice."
+ msg = f"group instance with id {nonexistent_group_id!r} is not a valid choice."
@mock_inputs(
{
diff --git a/tests/file_uploads/views.py b/tests/file_uploads/views.py
index f4f3b5c51458..49916845d0fb 100644
--- a/tests/file_uploads/views.py
+++ b/tests/file_uploads/views.py
@@ -157,7 +157,7 @@ def file_upload_filename_case_view(request):
file = request.FILES["file_field"]
obj = FileModel()
obj.testfile.save(file.name, file)
- return HttpResponse("%d" % obj.pk)
+ return HttpResponse("%s" % obj.pk)
def file_upload_content_type_extra(request):
diff --git a/tests/forms_tests/models.py b/tests/forms_tests/models.py
index d6d0725b32b3..b1319abe17f1 100644
--- a/tests/forms_tests/models.py
+++ b/tests/forms_tests/models.py
@@ -68,7 +68,7 @@ class Meta:
ordering = ("name",)
def __str__(self):
- return "ChoiceOption %d" % self.pk
+ return "ChoiceOption %s" % self.pk
def choice_default():
diff --git a/tests/generic_views/test_dates.py b/tests/generic_views/test_dates.py
index 140083d31538..d12b1f6baa61 100644
--- a/tests/generic_views/test_dates.py
+++ b/tests/generic_views/test_dates.py
@@ -895,7 +895,7 @@ def test_get_object_custom_queryset_numqueries(self):
def test_datetime_date_detail(self):
bs = BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0))
- res = self.client.get("/dates/booksignings/2008/apr/2/%d/" % bs.pk)
+ res = self.client.get("/dates/booksignings/2008/apr/2/%s/" % bs.pk)
self.assertEqual(res.status_code, 200)
@requires_tz_support
@@ -904,17 +904,17 @@ def test_aware_datetime_date_detail(self):
bs = BookSigning.objects.create(
event_date=datetime.datetime(2008, 4, 2, 12, 0, tzinfo=datetime.UTC)
)
- res = self.client.get("/dates/booksignings/2008/apr/2/%d/" % bs.pk)
+ res = self.client.get("/dates/booksignings/2008/apr/2/%s/" % bs.pk)
self.assertEqual(res.status_code, 200)
# 2008-04-02T00:00:00+03:00 (beginning of day) >
# 2008-04-01T22:00:00+00:00 (book signing event date).
bs.event_date = datetime.datetime(2008, 4, 1, 22, 0, tzinfo=datetime.UTC)
bs.save()
- res = self.client.get("/dates/booksignings/2008/apr/2/%d/" % bs.pk)
+ res = self.client.get("/dates/booksignings/2008/apr/2/%s/" % bs.pk)
self.assertEqual(res.status_code, 200)
# 2008-04-03T00:00:00+03:00 (end of day) > 2008-04-02T22:00:00+00:00
# (book signing event date).
bs.event_date = datetime.datetime(2008, 4, 2, 22, 0, tzinfo=datetime.UTC)
bs.save()
- res = self.client.get("/dates/booksignings/2008/apr/2/%d/" % bs.pk)
+ res = self.client.get("/dates/booksignings/2008/apr/2/%s/" % bs.pk)
self.assertEqual(res.status_code, 404)
diff --git a/tests/generic_views/test_edit.py b/tests/generic_views/test_edit.py
index d66b316090d0..b483ea1028ff 100644
--- a/tests/generic_views/test_edit.py
+++ b/tests/generic_views/test_edit.py
@@ -124,7 +124,7 @@ def test_create_with_object_url(self):
res = self.client.post("/edit/artists/create/", {"name": "Rene Magritte"})
self.assertEqual(res.status_code, 302)
artist = Artist.objects.get(name="Rene Magritte")
- self.assertRedirects(res, "/detail/artist/%d/" % artist.pk)
+ self.assertRedirects(res, "/detail/artist/%s/" % artist.pk)
self.assertQuerySetEqual(Artist.objects.all(), [artist])
def test_create_with_redirect(self):
@@ -148,7 +148,7 @@ def test_create_with_interpolated_redirect(self):
)
self.assertEqual(res.status_code, 302)
pk = Author.objects.first().pk
- self.assertRedirects(res, "/edit/author/%d/update/" % pk)
+ self.assertRedirects(res, "/edit/author/%s/update/" % pk)
# Also test with escaped chars in URL
res = self.client.post(
"/edit/authors/create/interpolate_redirect_nonascii/",
@@ -245,7 +245,7 @@ def setUpTestData(cls):
)
def test_update_post(self):
- res = self.client.get("/edit/author/%d/update/" % self.author.pk)
+ res = self.client.get("/edit/author/%s/update/" % self.author.pk)
self.assertEqual(res.status_code, 200)
self.assertIsInstance(res.context["form"], forms.ModelForm)
self.assertEqual(res.context["object"], self.author)
@@ -255,7 +255,7 @@ def test_update_post(self):
# Modification with both POST and PUT (browser compatible)
res = self.client.post(
- "/edit/author/%d/update/" % self.author.pk,
+ "/edit/author/%s/update/" % self.author.pk,
{"name": "Randall Munroe (xkcd)", "slug": "randall-munroe"},
)
self.assertEqual(res.status_code, 302)
@@ -266,7 +266,7 @@ def test_update_post(self):
def test_update_invalid(self):
res = self.client.post(
- "/edit/author/%d/update/" % self.author.pk,
+ "/edit/author/%s/update/" % self.author.pk,
{"name": "A" * 101, "slug": "randall-munroe"},
)
self.assertEqual(res.status_code, 200)
@@ -278,15 +278,15 @@ def test_update_invalid(self):
def test_update_with_object_url(self):
a = Artist.objects.create(name="Rene Magritte")
res = self.client.post(
- "/edit/artists/%d/update/" % a.pk, {"name": "Rene Magritte"}
+ "/edit/artists/%s/update/" % a.pk, {"name": "Rene Magritte"}
)
self.assertEqual(res.status_code, 302)
- self.assertRedirects(res, "/detail/artist/%d/" % a.pk)
+ self.assertRedirects(res, "/detail/artist/%s/" % a.pk)
self.assertQuerySetEqual(Artist.objects.all(), [a])
def test_update_with_redirect(self):
res = self.client.post(
- "/edit/author/%d/update/redirect/" % self.author.pk,
+ "/edit/author/%s/update/redirect/" % self.author.pk,
{"name": "Randall Munroe (author of xkcd)", "slug": "randall-munroe"},
)
self.assertEqual(res.status_code, 302)
@@ -298,7 +298,7 @@ def test_update_with_redirect(self):
def test_update_with_interpolated_redirect(self):
res = self.client.post(
- "/edit/author/%d/update/interpolate_redirect/" % self.author.pk,
+ "/edit/author/%s/update/interpolate_redirect/" % self.author.pk,
{"name": "Randall Munroe (author of xkcd)", "slug": "randall-munroe"},
)
self.assertQuerySetEqual(
@@ -307,10 +307,10 @@ def test_update_with_interpolated_redirect(self):
)
self.assertEqual(res.status_code, 302)
pk = Author.objects.first().pk
- self.assertRedirects(res, "/edit/author/%d/update/" % pk)
+ self.assertRedirects(res, "/edit/author/%s/update/" % pk)
# Also test with escaped chars in URL
res = self.client.post(
- "/edit/author/%d/update/interpolate_redirect_nonascii/" % self.author.pk,
+ "/edit/author/%s/update/interpolate_redirect_nonascii/" % self.author.pk,
{"name": "John Doe", "slug": "john-doe"},
)
self.assertEqual(res.status_code, 302)
@@ -318,7 +318,7 @@ def test_update_with_interpolated_redirect(self):
self.assertRedirects(res, "/%C3%A9dit/author/{}/update/".format(pk))
def test_update_with_special_properties(self):
- res = self.client.get("/edit/author/%d/update/special/" % self.author.pk)
+ res = self.client.get("/edit/author/%s/update/special/" % self.author.pk)
self.assertEqual(res.status_code, 200)
self.assertIsInstance(res.context["form"], views.AuthorForm)
self.assertEqual(res.context["object"], self.author)
@@ -327,11 +327,11 @@ def test_update_with_special_properties(self):
self.assertTemplateUsed(res, "generic_views/form.html")
res = self.client.post(
- "/edit/author/%d/update/special/" % self.author.pk,
+ "/edit/author/%s/update/special/" % self.author.pk,
{"name": "Randall Munroe (author of xkcd)", "slug": "randall-munroe"},
)
self.assertEqual(res.status_code, 302)
- self.assertRedirects(res, "/detail/author/%d/" % self.author.pk)
+ self.assertRedirects(res, "/detail/author/%s/" % self.author.pk)
self.assertQuerySetEqual(
Author.objects.values_list("name", flat=True),
["Randall Munroe (author of xkcd)"],
@@ -344,7 +344,7 @@ def test_update_without_redirect(self):
)
with self.assertRaisesMessage(ImproperlyConfigured, msg):
self.client.post(
- "/edit/author/%d/update/naive/" % self.author.pk,
+ "/edit/author/%s/update/naive/" % self.author.pk,
{"name": "Randall Munroe (author of xkcd)", "slug": "randall-munroe"},
)
@@ -379,37 +379,37 @@ def setUpTestData(cls):
)
def test_delete_by_post(self):
- res = self.client.get("/edit/author/%d/delete/" % self.author.pk)
+ res = self.client.get("/edit/author/%s/delete/" % self.author.pk)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.context["object"], self.author)
self.assertEqual(res.context["author"], self.author)
self.assertTemplateUsed(res, "generic_views/author_confirm_delete.html")
# Deletion with POST
- res = self.client.post("/edit/author/%d/delete/" % self.author.pk)
+ res = self.client.post("/edit/author/%s/delete/" % self.author.pk)
self.assertEqual(res.status_code, 302)
self.assertRedirects(res, "/list/authors/")
self.assertQuerySetEqual(Author.objects.all(), [])
def test_delete_by_delete(self):
# Deletion with browser compatible DELETE method
- res = self.client.delete("/edit/author/%d/delete/" % self.author.pk)
+ res = self.client.delete("/edit/author/%s/delete/" % self.author.pk)
self.assertEqual(res.status_code, 302)
self.assertRedirects(res, "/list/authors/")
self.assertQuerySetEqual(Author.objects.all(), [])
def test_delete_with_redirect(self):
- res = self.client.post("/edit/author/%d/delete/redirect/" % self.author.pk)
+ res = self.client.post("/edit/author/%s/delete/redirect/" % self.author.pk)
self.assertEqual(res.status_code, 302)
self.assertRedirects(res, "/edit/authors/create/")
self.assertQuerySetEqual(Author.objects.all(), [])
def test_delete_with_interpolated_redirect(self):
res = self.client.post(
- "/edit/author/%d/delete/interpolate_redirect/" % self.author.pk
+ "/edit/author/%s/delete/interpolate_redirect/" % self.author.pk
)
self.assertEqual(res.status_code, 302)
- self.assertRedirects(res, "/edit/authors/create/?deleted=%d" % self.author.pk)
+ self.assertRedirects(res, "/edit/authors/create/?deleted=%s" % self.author.pk)
self.assertQuerySetEqual(Author.objects.all(), [])
# Also test with escaped chars in URL
a = Author.objects.create(
@@ -422,14 +422,14 @@ def test_delete_with_interpolated_redirect(self):
self.assertRedirects(res, "/%C3%A9dit/authors/create/?deleted={}".format(a.pk))
def test_delete_with_special_properties(self):
- res = self.client.get("/edit/author/%d/delete/special/" % self.author.pk)
+ res = self.client.get("/edit/author/%s/delete/special/" % self.author.pk)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.context["object"], self.author)
self.assertEqual(res.context["thingy"], self.author)
self.assertNotIn("author", res.context)
self.assertTemplateUsed(res, "generic_views/confirm_delete.html")
- res = self.client.post("/edit/author/%d/delete/special/" % self.author.pk)
+ res = self.client.post("/edit/author/%s/delete/special/" % self.author.pk)
self.assertEqual(res.status_code, 302)
self.assertRedirects(res, "/list/authors/")
self.assertQuerySetEqual(Author.objects.all(), [])
@@ -437,29 +437,29 @@ def test_delete_with_special_properties(self):
def test_delete_without_redirect(self):
msg = "No URL to redirect to. Provide a success_url."
with self.assertRaisesMessage(ImproperlyConfigured, msg):
- self.client.post("/edit/author/%d/delete/naive/" % self.author.pk)
+ self.client.post("/edit/author/%s/delete/naive/" % self.author.pk)
def test_delete_with_form_as_post(self):
- res = self.client.get("/edit/author/%d/delete/form/" % self.author.pk)
+ res = self.client.get("/edit/author/%s/delete/form/" % self.author.pk)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.context["object"], self.author)
self.assertEqual(res.context["author"], self.author)
self.assertTemplateUsed(res, "generic_views/author_confirm_delete.html")
res = self.client.post(
- "/edit/author/%d/delete/form/" % self.author.pk, data={"confirm": True}
+ "/edit/author/%s/delete/form/" % self.author.pk, data={"confirm": True}
)
self.assertEqual(res.status_code, 302)
self.assertRedirects(res, "/list/authors/")
self.assertSequenceEqual(Author.objects.all(), [])
def test_delete_with_form_as_post_with_validation_error(self):
- res = self.client.get("/edit/author/%d/delete/form/" % self.author.pk)
+ res = self.client.get("/edit/author/%s/delete/form/" % self.author.pk)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.context["object"], self.author)
self.assertEqual(res.context["author"], self.author)
self.assertTemplateUsed(res, "generic_views/author_confirm_delete.html")
- res = self.client.post("/edit/author/%d/delete/form/" % self.author.pk)
+ res = self.client.post("/edit/author/%s/delete/form/" % self.author.pk)
self.assertEqual(res.status_code, 200)
self.assertEqual(len(res.context_data["form"].errors), 2)
self.assertEqual(
diff --git a/tests/many_to_one/tests.py b/tests/many_to_one/tests.py
index 4d2343e304fd..193e6e7086b3 100644
--- a/tests/many_to_one/tests.py
+++ b/tests/many_to_one/tests.py
@@ -889,7 +889,7 @@ def test_reverse_foreign_key_instance_to_field_caching(self):
def test_add_remove_set_by_pk_raises(self):
usa = Country.objects.create(name="United States")
chicago = City.objects.create(name="Chicago")
- msg = "'City' instance expected, got %s" % chicago.pk
+ msg = "'City' instance expected, got %r" % chicago.pk
with self.assertRaisesMessage(TypeError, msg):
usa.cities.add(chicago.pk)
with self.assertRaisesMessage(TypeError, msg):
diff --git a/tests/model_forms/test_modelchoicefield.py b/tests/model_forms/test_modelchoicefield.py
index 7b086fb182d0..7f66b5b07867 100644
--- a/tests/model_forms/test_modelchoicefield.py
+++ b/tests/model_forms/test_modelchoicefield.py
@@ -347,11 +347,11 @@ class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField):
field.widget.render("name", []),
(
""
)
@@ -393,14 +393,14 @@ class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField):
field.widget.render("name", []),
"""
""" % (self.c1.pk, self.c2.pk, self.c3.pk),
)
diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py
index 466674ef6488..3d4eb06cf4b7 100644
--- a/tests/model_forms/tests.py
+++ b/tests/model_forms/tests.py
@@ -1650,9 +1650,9 @@ def formfield_for_dbfield(db_field, **kwargs):
Categories:
-Entertainment
-It's a test
-Third test
+Entertainment
+It's a test
+Third test
""" % (self.c1.pk, self.c2.pk, self.c3.pk),
)
diff --git a/tests/model_formsets/tests.py b/tests/model_formsets/tests.py
index 4e5c0e39c5e2..02c928cba230 100644
--- a/tests/model_formsets/tests.py
+++ b/tests/model_formsets/tests.py
@@ -234,7 +234,7 @@ def test_simple_save(self):
'Name: '
' '
- '
'
+ '
'
% author2.id,
)
self.assertHTMLEqual(
@@ -242,7 +242,7 @@ def test_simple_save(self):
'Name: '
' '
- '
'
+ ' '
% author1.id,
)
self.assertHTMLEqual(
@@ -292,7 +292,7 @@ def test_simple_save(self):
'value="Arthur Rimbaud" maxlength="100">'
'Delete: '
' '
- '
'
+ ' '
% author2.id,
)
self.assertHTMLEqual(
@@ -302,7 +302,7 @@ def test_simple_save(self):
'value="Charles Baudelaire" maxlength="100">'
'Delete: '
' '
- '
'
+ ' '
% author1.id,
)
self.assertHTMLEqual(
@@ -312,7 +312,7 @@ def test_simple_save(self):
'value="Paul Verlaine" maxlength="100">'
'Delete: '
' '
- '
'
+ ' '
% author3.id,
)
self.assertHTMLEqual(
@@ -604,7 +604,7 @@ def test_model_inheritance(self):
'Write speed: '
' '
- '
' % hemingway_id,
)
self.assertHTMLEqual(
@@ -649,7 +649,7 @@ def test_inline_formsets(self):
'Title: '
' '
- ' '
' '
"
" % author.id,
@@ -659,7 +659,7 @@ def test_inline_formsets(self):
'Title: '
' '
- ' '
'
'
% author.id,
@@ -669,7 +669,7 @@ def test_inline_formsets(self):
'Title: '
' '
- ' '
'
'
% author.id,
@@ -709,9 +709,9 @@ def test_inline_formsets(self):
'Title: '
' '
- ' '
- '
'
% (
author.id,
@@ -723,7 +723,7 @@ def test_inline_formsets(self):
'Title: '
' '
- ' '
'
'
% author.id,
@@ -733,7 +733,7 @@ def test_inline_formsets(self):
'Title: '
' '
- ' '
'
'
% author.id,
@@ -1216,7 +1216,7 @@ def test_custom_pk(self):
'value="Joe Perry" maxlength="100">'
' '
- ' ' % owner1.auto_id,
)
self.assertHTMLEqual(
@@ -1268,8 +1268,8 @@ def test_custom_pk(self):
'Owner: '
''
'--------- '
- 'Joe Perry at Giordanos '
- 'Jack Berry at Giordanos '
+ 'Joe Perry at Giordanos '
+ 'Jack Berry at Giordanos '
"
"
'Age: '
'
'
@@ -1289,7 +1289,7 @@ def test_custom_pk(self):
'Age: '
' '
- '
' % owner1.auto_id,
)
@@ -1315,7 +1315,7 @@ def test_custom_pk(self):
'Age: '
' '
- '
' % owner1.auto_id,
)
@@ -1589,7 +1589,7 @@ def test_callable_defaults(self):
'Karma: '
' '
- ' '
'
' % person.id,
diff --git a/tests/modeladmin/tests.py b/tests/modeladmin/tests.py
index 4a92514e177f..f50a557d0241 100644
--- a/tests/modeladmin/tests.py
+++ b/tests/modeladmin/tests.py
@@ -637,8 +637,8 @@ def test_queryset_override(self):
''
'--------- '
- 'The Beatles '
- 'The Doors '
+ 'The Beatles '
+ 'The Doors '
" " % (band2.id, self.band.id),
)
@@ -661,7 +661,7 @@ class ConcertAdminWithForm(ModelAdmin):
''
'--------- '
- 'The Doors '
+ 'The Doors '
" " % self.band.id,
)
diff --git a/tests/serializers/test_data.py b/tests/serializers/test_data.py
index 121404a0ef46..744c9d1a2b3c 100644
--- a/tests/serializers/test_data.py
+++ b/tests/serializers/test_data.py
@@ -170,7 +170,7 @@ def data_compare(testcase, pk, klass, data):
testcase.assertEqual(
bytes(data),
bytes(instance.data),
- "Objects with PK=%d not equal; expected '%s' (%s), got '%s' (%s)"
+ "Objects with PK=%s not equal; expected '%s' (%s), got '%s' (%s)"
% (
pk,
repr(bytes(data)),
@@ -183,7 +183,7 @@ def data_compare(testcase, pk, klass, data):
testcase.assertEqual(
data,
instance.data,
- "Objects with PK=%d not equal; expected '%s' (%s), got '%s' (%s)"
+ "Objects with PK=%s not equal; expected '%s' (%s), got '%s' (%s)"
% (
pk,
data,
diff --git a/tests/serializers/test_natural.py b/tests/serializers/test_natural.py
index e5592f97c8ee..b405dc3e284b 100644
--- a/tests/serializers/test_natural.py
+++ b/tests/serializers/test_natural.py
@@ -44,7 +44,7 @@ def natural_key_serializer_test(self, format):
self.assertEqual(
obj.data,
instance.data,
- "Objects with PK=%d not equal; expected '%s' (%s), got '%s' (%s)"
+ "Objects with PK=%s not equal; expected '%s' (%s), got '%s' (%s)"
% (
obj.pk,
obj.data,
From ef46215fdecb755c0b77c47fdb8fec670abb5e8b Mon Sep 17 00:00:00 2001
From: Tim Graham
Date: Mon, 9 Feb 2026 20:28:05 -0500
Subject: [PATCH 05/11] Added various missing test skips observed on MongoDB.
---
tests/admin_changelist/tests.py | 2 +-
tests/backends/base/test_base.py | 1 +
tests/fixtures_regress/tests.py | 1 +
tests/gis_tests/geoapp/test_expressions.py | 1 +
tests/migrations/test_executor.py | 1 +
tests/migrations/test_operations.py | 1 +
tests/test_utils/test_testcase.py | 7 +++----
tests/test_utils/tests.py | 3 ++-
tests/transaction_hooks/tests.py | 3 +++
tests/transactions/tests.py | 3 +++
10 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py
index 626558f496c8..a36574d4dfb9 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -454,7 +454,7 @@ def test_result_list_editable(self):
with self.assertRaises(IncorrectLookupParameters):
m.get_changelist_instance(request)
- @skipUnlessDBFeature("supports_transactions")
+ @skipUnlessDBFeature("uses_savepoints")
def test_list_editable_atomicity(self):
a = Swallow.objects.create(origin="Swallow A", load=4, speed=1)
b = Swallow.objects.create(origin="Swallow B", load=2, speed=2)
diff --git a/tests/backends/base/test_base.py b/tests/backends/base/test_base.py
index 120584e7fcf5..2d19840851fb 100644
--- a/tests/backends/base/test_base.py
+++ b/tests/backends/base/test_base.py
@@ -93,6 +93,7 @@ def test_release_memory_without_garbage_collection(self):
self.assertEqual(gc.garbage, [])
+@skipUnlessDBFeature("supports_transactions")
class DatabaseWrapperLoggingTests(TransactionTestCase):
available_apps = ["backends"]
diff --git a/tests/fixtures_regress/tests.py b/tests/fixtures_regress/tests.py
index 999555effe43..156c403c2343 100644
--- a/tests/fixtures_regress/tests.py
+++ b/tests/fixtures_regress/tests.py
@@ -499,6 +499,7 @@ def test_loaddata_works_when_fixture_has_forward_refs(self):
self.assertEqual(Book.objects.all()[0].id, 1)
self.assertEqual(Person.objects.all()[0].id, 4)
+ @skipUnlessDBFeature("supports_foreign_keys")
def test_loaddata_raises_error_when_fixture_has_invalid_foreign_key(self):
"""
Data with nonexistent child key references raises error.
diff --git a/tests/gis_tests/geoapp/test_expressions.py b/tests/gis_tests/geoapp/test_expressions.py
index b56832bb6f66..a93bffdbbcc9 100644
--- a/tests/gis_tests/geoapp/test_expressions.py
+++ b/tests/gis_tests/geoapp/test_expressions.py
@@ -54,6 +54,7 @@ def test_update_from_other_field(self):
obj.point3.equals_exact(p1.transform(3857, clone=True), 0.1)
)
+ @skipUnlessDBFeature("has_Distance_function")
def test_multiple_annotation(self):
multi_field = MultiFields.objects.create(
point=Point(1, 1),
diff --git a/tests/migrations/test_executor.py b/tests/migrations/test_executor.py
index dd6793b533b0..060a35e379c9 100644
--- a/tests/migrations/test_executor.py
+++ b/tests/migrations/test_executor.py
@@ -162,6 +162,7 @@ def test_non_atomic_migration(self):
self.assertTrue(Publisher.objects.exists())
self.assertTableNotExists("migrations_book")
+ @skipUnlessDBFeature("supports_transactions")
@override_settings(
MIGRATION_MODULES={"migrations": "migrations.test_migrations_atomic_operation"}
)
diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py
index 90c22bc73bc7..85295d8e5099 100644
--- a/tests/migrations/test_operations.py
+++ b/tests/migrations/test_operations.py
@@ -5809,6 +5809,7 @@ def test_run_python_invalid_reverse_code(self):
with self.assertRaisesMessage(ValueError, msg):
migrations.RunPython(code=migrations.RunPython.noop, reverse_code="invalid")
+ @skipUnlessDBFeature("supports_transactions")
def test_run_python_atomic(self):
"""
Tests the RunPython operation correctly handles the "atomic" keyword
diff --git a/tests/test_utils/test_testcase.py b/tests/test_utils/test_testcase.py
index 866e0dccc6c7..9d1b25fb47c8 100644
--- a/tests/test_utils/test_testcase.py
+++ b/tests/test_utils/test_testcase.py
@@ -82,10 +82,9 @@ def inner(self):
return inner
-# On databases with no transaction support (for instance, MySQL with the MyISAM
-# engine), setUpTestData() is called before each test, so there is no need to
-# clone class level test data.
-@skipUnlessDBFeature("supports_transactions")
+# On databases without savepoint support, setUpTestData() is called before each
+# test, so there's no need to clone class-level test data.
+@skipUnlessDBFeature("uses_savepoints")
class TestDataTests(TestCase):
# setUpTestData re-assignment are also wrapped in TestData.
jim_douglas = None
diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py
index 22b509b6b3f2..ef1d02cf4160 100644
--- a/tests/test_utils/tests.py
+++ b/tests/test_utils/tests.py
@@ -1953,7 +1953,7 @@ def test_override_staticfiles_dirs(self):
self.assertIn(expected_location, finder.locations)
-@skipUnlessDBFeature("supports_transactions")
+@skipUnlessDBFeature("uses_savepoints")
class TestBadSetUpTestData(TestCase):
"""
An exception in setUpTestData() shouldn't leak a transaction which would
@@ -2041,6 +2041,7 @@ def pre_hook():
self.assertEqual(len(callbacks), 1)
self.assertNotEqual(callbacks[0], pre_hook)
+ @skipUnlessDBFeature("uses_savepoints")
def test_with_rolled_back_savepoint(self):
with self.captureOnCommitCallbacks() as callbacks:
try:
diff --git a/tests/transaction_hooks/tests.py b/tests/transaction_hooks/tests.py
index 539016507260..3bf18e73e392 100644
--- a/tests/transaction_hooks/tests.py
+++ b/tests/transaction_hooks/tests.py
@@ -155,6 +155,7 @@ def test_executes_only_after_final_transaction_committed(self):
self.assertNotified([])
self.assertDone([1])
+ @skipUnlessDBFeature("uses_savepoints")
def test_discards_hooks_from_rolled_back_savepoint(self):
with transaction.atomic():
# one successful savepoint
@@ -186,6 +187,7 @@ def test_no_hooks_run_from_failed_transaction(self):
self.assertDone([])
+ @skipUnlessDBFeature("uses_savepoints")
def test_inner_savepoint_rolled_back_with_outer(self):
with transaction.atomic():
try:
@@ -211,6 +213,7 @@ def test_no_savepoints_atomic_merged_with_outer(self):
self.assertDone([])
+ @skipUnlessDBFeature("uses_savepoints")
def test_inner_savepoint_does_not_affect_outer(self):
with transaction.atomic():
with transaction.atomic():
diff --git a/tests/transactions/tests.py b/tests/transactions/tests.py
index a3cd4cafeec3..bff1a30cf2ee 100644
--- a/tests/transactions/tests.py
+++ b/tests/transactions/tests.py
@@ -467,6 +467,7 @@ def test_atomic_does_not_leak_savepoints_on_failure(self):
# exists.
connection.savepoint_rollback(sid)
+ @skipUnlessDBFeature("supports_transactions")
def test_mark_for_rollback_on_error_in_transaction(self):
with transaction.atomic(savepoint=False):
# Swallow the intentional error raised.
@@ -512,6 +513,7 @@ def test_mark_for_rollback_on_error_in_autocommit(self):
Reporter.objects.create()
+@skipUnlessDBFeature("supports_transactions")
class NonAutocommitTests(TransactionTestCase):
available_apps = []
@@ -520,6 +522,7 @@ def setUp(self):
self.addCleanup(transaction.set_autocommit, True)
self.addCleanup(transaction.rollback)
+ @skipUnlessDBFeature("supports_foreign_keys")
def test_orm_query_after_error_and_rollback(self):
"""
ORM queries are allowed after an error and a rollback in non-autocommit
From 220db1c78a1bdeb3ccb91ba8bf0b7ab829379c35 Mon Sep 17 00:00:00 2001
From: "Michiel W. Beijen"
Date: Tue, 10 Feb 2026 22:14:26 +0100
Subject: [PATCH 06/11] Refs #35961 -- Restored AUTHORS in wheel.
As a side effect from adding explicit license files to conform to PEP 639, the
AUTHORS file got dropped from the wheel. The tarball still contained this file.
In the "Python Packaging User Guide"
(https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license-files)
the AUTHORS file is modeled to be included in license-files.
Follow-up to 96a7a652166bece8acc96d6335ebb8091de2f496.
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 38a4457c0ec8..c64cd28dab46 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,7 @@ authors = [
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
readme = "README.rst"
license = "BSD-3-Clause"
-license-files = ["LICENSE", "LICENSE.python"]
+license-files = ["LICENSE", "LICENSE.python", "AUTHORS"]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Environment :: Web Environment",
From 977345f70df64a1d524e185e05b956e57c612110 Mon Sep 17 00:00:00 2001
From: David Smith
Date: Mon, 12 Jan 2026 20:38:34 +0000
Subject: [PATCH 07/11] Fixed #36854 -- Updated GEOS init and finish bindings.
---
django/contrib/gis/geos/libgeos.py | 11 +++++++----
django/contrib/gis/geos/prototypes/threadsafe.py | 8 ++++----
2 files changed, 11 insertions(+), 8 deletions(-)
diff --git a/django/contrib/gis/geos/libgeos.py b/django/contrib/gis/geos/libgeos.py
index feb225cf8c13..3222edb0d848 100644
--- a/django/contrib/gis/geos/libgeos.py
+++ b/django/contrib/gis/geos/libgeos.py
@@ -60,12 +60,15 @@ def load_geos():
# See the GEOS C API source code for more details on the library function
# calls: https://libgeos.org/doxygen/geos__c_8h_source.html
_lgeos = CDLL(lib_path)
- # Here we set up the prototypes for the initGEOS_r and finishGEOS_r
- # routines. These functions aren't actually called until they are
+ # Here we set up the prototypes for the GEOS_init_r and GEOS_finish_r
+ # routines, as well as the context handler setters.
+ # These functions aren't actually called until they are
# attached to a GEOS context handle -- this actually occurs in
# geos/prototypes/threadsafe.py.
- _lgeos.initGEOS_r.restype = CONTEXT_PTR
- _lgeos.finishGEOS_r.argtypes = [CONTEXT_PTR]
+ _lgeos.GEOS_init_r.restype = CONTEXT_PTR
+ _lgeos.GEOS_finish_r.argtypes = [CONTEXT_PTR]
+ _lgeos.GEOSContext_setErrorHandler_r.argtypes = [CONTEXT_PTR, ERRORFUNC]
+ _lgeos.GEOSContext_setNoticeHandler_r.argtypes = [CONTEXT_PTR, NOTICEFUNC]
# Set restype for compatibility across 32 and 64-bit platforms.
_lgeos.GEOSversion.restype = c_char_p
return _lgeos
diff --git a/django/contrib/gis/geos/prototypes/threadsafe.py b/django/contrib/gis/geos/prototypes/threadsafe.py
index d4f7ffb8ac94..48e13ebfb93d 100644
--- a/django/contrib/gis/geos/prototypes/threadsafe.py
+++ b/django/contrib/gis/geos/prototypes/threadsafe.py
@@ -8,12 +8,12 @@ class GEOSContextHandle(GEOSBase):
"""Represent a GEOS context handle."""
ptr_type = CONTEXT_PTR
- destructor = lgeos.finishGEOS_r
+ destructor = lgeos.GEOS_finish_r
def __init__(self):
- # Initializing the context handler for this thread with
- # the notice and error handler.
- self.ptr = lgeos.initGEOS_r(notice_h, error_h)
+ self.ptr = lgeos.GEOS_init_r()
+ lgeos.GEOSContext_setNoticeHandler_r(self.ptr, notice_h)
+ lgeos.GEOSContext_setErrorHandler_r(self.ptr, error_h)
# Defining a thread-local object and creating an instance
From 3282d9f4edbe5d341a0fa2a8c62b435b3885ab64 Mon Sep 17 00:00:00 2001
From: varunkasyap
Date: Mon, 2 Feb 2026 13:50:16 +0530
Subject: [PATCH 08/11] Fixed #36890 -- Supported StringAgg(distinct=True) on
SQLite with the default delimiter.
---
django/db/models/aggregates.py | 18 ++++++++++++++++++
docs/ref/models/querysets.txt | 9 ++++++++-
docs/releases/6.1.txt | 3 +++
tests/aggregation/tests.py | 13 ++++++++++++-
4 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/django/db/models/aggregates.py b/django/db/models/aggregates.py
index 1cf82416cb73..d1139e8bcc3c 100644
--- a/django/db/models/aggregates.py
+++ b/django/db/models/aggregates.py
@@ -369,6 +369,24 @@ def as_mysql(self, compiler, connection, **extra_context):
return sql, (*params, *delimiter_params)
def as_sqlite(self, compiler, connection, **extra_context):
+ if (
+ self.distinct
+ and isinstance(self.delimiter.value, Value)
+ and self.delimiter.value.value == ","
+ ):
+ clone = self.copy()
+ source_expressions = clone.get_source_expressions()
+ clone.set_source_expressions(
+ source_expressions[:1] + source_expressions[2:]
+ )
+
+ return clone.as_sql(
+ compiler,
+ connection,
+ function="GROUP_CONCAT",
+ **extra_context,
+ )
+
if connection.get_database_version() < (3, 44):
return self.as_sql(
compiler,
diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt
index c819015b2517..579c0de30234 100644
--- a/docs/ref/models/querysets.txt
+++ b/docs/ref/models/querysets.txt
@@ -4175,7 +4175,14 @@ by the aggregate.
.. attribute:: delimiter
A ``Value`` or expression representing the string that should separate
- each of the values. For example, ``Value(",")``.
+ each of the values. For example, ``Value(",")``. (On SQLite, the
+ literal delimiter ``Value(",")`` is the only delimiter compatible with
+ ``distinct=True``.)
+
+ .. versionchanged:: 6.1
+
+ Support for using ``distinct=True`` with a delimiter of
+ ``Value(",")`` on SQLite was added.
Query-related tools
===================
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index 0d3598254381..af783136ad5e 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -284,6 +284,9 @@ Models
* The :data:`~django.db.models.signals.m2m_changed` signal now receives a
``raw`` argument.
+* :class:`~django.db.models.StringAgg` now supports ``distinct=True`` on SQLite
+ when using the default delimiter ``Value(",")`` only.
+
Pagination
~~~~~~~~~~
diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py
index bf6bf2703112..0a975dcb529e 100644
--- a/tests/aggregation/tests.py
+++ b/tests/aggregation/tests.py
@@ -3,6 +3,7 @@
import re
from decimal import Decimal
from itertools import chain
+from unittest import skipUnless
from django.core.exceptions import FieldError
from django.db import NotSupportedError, connection
@@ -579,6 +580,16 @@ def test_distinct_on_stringagg(self):
)
self.assertCountEqual(books["ratings"].split(","), ["3", "4", "4.5", "5"])
+ @skipUnless(connection.vendor == "sqlite", "Special default case for SQLite.")
+ def test_distinct_on_stringagg_sqlite_special_case(self):
+ """
+ Value(",") is the only delimiter usable on SQLite with distinct=True.
+ """
+ books = Book.objects.aggregate(
+ ratings=StringAgg(Cast(F("rating"), CharField()), Value(","), distinct=True)
+ )
+ self.assertCountEqual(books["ratings"].split(","), ["3.0", "4.0", "4.5", "5.0"])
+
@skipIfDBFeature("supports_aggregate_distinct_multiple_argument")
def test_raises_error_on_multiple_argument_distinct(self):
message = (
@@ -589,7 +600,7 @@ def test_raises_error_on_multiple_argument_distinct(self):
Book.objects.aggregate(
ratings=StringAgg(
Cast(F("rating"), CharField()),
- Value(","),
+ Value(";"),
distinct=True,
)
)
From 2c2d36376a0ce0edc048c077a60be6e3b953bb09 Mon Sep 17 00:00:00 2001
From: Jacob Walls
Date: Mon, 9 Feb 2026 16:05:55 -0500
Subject: [PATCH 09/11] Added stub release notes for 5.2.12.
---
docs/releases/5.2.12.txt | 12 ++++++++++++
docs/releases/index.txt | 1 +
2 files changed, 13 insertions(+)
create mode 100644 docs/releases/5.2.12.txt
diff --git a/docs/releases/5.2.12.txt b/docs/releases/5.2.12.txt
new file mode 100644
index 000000000000..ac344f1d1d6f
--- /dev/null
+++ b/docs/releases/5.2.12.txt
@@ -0,0 +1,12 @@
+===========================
+Django 5.2.12 release notes
+===========================
+
+*Expected March 3, 2026*
+
+Django 5.2.12 fixes one bug related to support for Python 3.14.
+
+Bugfixes
+========
+
+* ...
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index 1ef9168032b2..390876eadc69 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -42,6 +42,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
+ 5.2.12
5.2.11
5.2.10
5.2.9
From 56ed37e17e5b1a509aa68a0c797dcff34fcc1366 Mon Sep 17 00:00:00 2001
From: 93578237 <43147888+93578237@users.noreply.github.com>
Date: Mon, 9 Feb 2026 16:06:50 -0500
Subject: [PATCH 10/11] Fixed #36903 -- Fixed further NameErrors when
inspecting functions with deferred annotations.
Provide a wrapper for safe introspection of user functions on Python 3.14+.
Follow-up to 601914722956cc41f1f2c53972d669ddee6ffc04.
---
django/contrib/auth/__init__.py | 4 +--
django/core/checks/security/csrf.py | 5 ++--
django/core/checks/urls.py | 5 ++--
django/db/models/expressions.py | 4 +--
django/template/base.py | 6 ++--
django/utils/deprecation.py | 3 +-
django/utils/inspect.py | 22 +++++++-------
docs/releases/5.2.12.txt | 3 +-
docs/releases/6.0.3.txt | 3 +-
tests/auth_tests/test_auth_backends.py | 30 +++++++++++++++++++
tests/check_framework/test_security.py | 21 +++++++++++++
tests/check_framework/test_urls.py | 11 +++++++
...good_error_handler_deferred_annotations.py | 15 ++++++++++
tests/deprecation/test_deprecate_posargs.py | 17 +++++++++++
tests/expressions/tests.py | 13 ++++++++
tests/template_tests/tests.py | 21 +++++++++++++
16 files changed, 157 insertions(+), 26 deletions(-)
create mode 100644 tests/check_framework/urls/good_error_handler_deferred_annotations.py
diff --git a/django/contrib/auth/__init__.py b/django/contrib/auth/__init__.py
index 8a0bcb7420c9..2702c38aa4d3 100644
--- a/django/contrib/auth/__init__.py
+++ b/django/contrib/auth/__init__.py
@@ -1,4 +1,3 @@
-import inspect
import re
from django.apps import apps as django_apps
@@ -6,6 +5,7 @@
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.middleware.csrf import rotate_token
from django.utils.crypto import constant_time_compare
+from django.utils.inspect import signature
from django.utils.module_loading import import_string
from django.views.decorators.debug import sensitive_variables
@@ -40,7 +40,7 @@ def get_backends():
def _get_compatible_backends(request, **credentials):
for backend, backend_path in _get_backends(return_tuples=True):
- backend_signature = inspect.signature(backend.authenticate)
+ backend_signature = signature(backend.authenticate)
try:
backend_signature.bind(request, **credentials)
except TypeError:
diff --git a/django/core/checks/security/csrf.py b/django/core/checks/security/csrf.py
index dee29384eeea..a4d03b5abb3d 100644
--- a/django/core/checks/security/csrf.py
+++ b/django/core/checks/security/csrf.py
@@ -1,7 +1,6 @@
-import inspect
-
from django.conf import settings
from django.core.checks import Error, Tags, Warning, register
+from django.utils.inspect import signature
W003 = Warning(
"You don't appear to be using Django's built-in "
@@ -56,7 +55,7 @@ def check_csrf_failure_view(app_configs, **kwargs):
errors.append(Error(msg, id="security.E102"))
else:
try:
- inspect.signature(view).bind(None, reason=None)
+ signature(view).bind(None, reason=None)
except TypeError:
msg = (
"The CSRF failure view '%s' does not take the correct number of "
diff --git a/django/core/checks/urls.py b/django/core/checks/urls.py
index 94bf8f990dd5..57141bd025ed 100644
--- a/django/core/checks/urls.py
+++ b/django/core/checks/urls.py
@@ -1,8 +1,8 @@
-import inspect
from collections import Counter
from django.conf import settings
from django.core.exceptions import ViewDoesNotExist
+from django.utils.inspect import signature
from . import Error, Tags, Warning, register
@@ -142,10 +142,9 @@ def check_custom_error_handlers(app_configs, **kwargs):
).format(status_code=status_code, path=path)
errors.append(Error(msg, hint=str(e), id="urls.E008"))
continue
- signature = inspect.signature(handler)
args = [None] * num_parameters
try:
- signature.bind(*args)
+ signature(handler).bind(*args)
except TypeError:
msg = (
"The custom handler{status_code} view '{path}' does not "
diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py
index baa91cc2c173..63d0c1802b49 100644
--- a/django/db/models/expressions.py
+++ b/django/db/models/expressions.py
@@ -1,7 +1,6 @@
import copy
import datetime
import functools
-import inspect
from collections import defaultdict
from decimal import Decimal
from enum import Enum
@@ -17,6 +16,7 @@
from django.utils.deconstruct import deconstructible
from django.utils.functional import cached_property, classproperty
from django.utils.hashable import make_hashable
+from django.utils.inspect import signature
class SQLiteNumericMixin:
@@ -523,7 +523,7 @@ class Expression(BaseExpression, Combinable):
@classproperty
@functools.lru_cache(maxsize=128)
def _constructor_signature(cls):
- return inspect.signature(cls.__init__)
+ return signature(cls.__init__)
@classmethod
def _identity(cls, value):
diff --git a/django/template/base.py b/django/template/base.py
index d6595e38e701..9d75111e4240 100644
--- a/django/template/base.py
+++ b/django/template/base.py
@@ -60,7 +60,7 @@
from django.utils.deprecation import django_file_prefixes
from django.utils.formats import localize
from django.utils.html import conditional_escape
-from django.utils.inspect import lazy_annotations
+from django.utils.inspect import lazy_annotations, signature
from django.utils.regex_helper import _lazy_re_compile
from django.utils.safestring import SafeData, SafeString, mark_safe
from django.utils.text import get_text_list, smart_split, unescape_string_literal
@@ -1000,12 +1000,12 @@ def _resolve_lookup(self, context):
current = current()
except TypeError:
try:
- signature = inspect.signature(current)
+ current_signature = signature(current)
except ValueError: # No signature found.
current = context.template.engine.string_if_invalid
else:
try:
- signature.bind()
+ current_signature.bind()
except TypeError: # Arguments *were* required.
# Invalid method call.
current = context.template.engine.string_if_invalid
diff --git a/django/utils/deprecation.py b/django/utils/deprecation.py
index 6b017cd91731..daa485eb35bf 100644
--- a/django/utils/deprecation.py
+++ b/django/utils/deprecation.py
@@ -8,6 +8,7 @@
from asgiref.sync import sync_to_async
import django
+from django.utils.inspect import signature
@functools.cache
@@ -163,7 +164,7 @@ def decorator(func):
if isinstance(func, staticmethod):
raise TypeError("Apply @staticmethod before @deprecate_posargs.")
- params = inspect.signature(func).parameters
+ params = signature(func).parameters
num_by_kind = Counter(param.kind for param in params.values())
if num_by_kind[inspect.Parameter.VAR_POSITIONAL] > 0:
diff --git a/django/utils/inspect.py b/django/utils/inspect.py
index f0f43ae17e35..a04669fc116a 100644
--- a/django/utils/inspect.py
+++ b/django/utils/inspect.py
@@ -17,16 +17,7 @@
@functools.lru_cache(maxsize=512)
def _get_func_parameters(func, remove_first):
- # As the annotations are not used in any case, inspect the signature with
- # FORWARDREF to leave any deferred annotations unevaluated.
- if PY314:
- signature = inspect.signature(
- func, annotation_format=annotationlib.Format.FORWARDREF
- )
- else:
- signature = inspect.signature(func)
-
- parameters = tuple(signature.parameters.values())
+ parameters = tuple(signature(func).parameters.values())
if remove_first:
parameters = parameters[1:]
return parameters
@@ -130,3 +121,14 @@ def lazy_annotations():
yield
finally:
inspect._signature_from_callable = original_helper
+
+
+def signature(obj):
+ """
+ A wrapper around inspect.signature that leaves deferred annotations
+ unevaluated on Python 3.14+, since they are not used in our case.
+ """
+ if PY314:
+ return inspect.signature(obj, annotation_format=annotationlib.Format.FORWARDREF)
+ else:
+ return inspect.signature(obj)
diff --git a/docs/releases/5.2.12.txt b/docs/releases/5.2.12.txt
index ac344f1d1d6f..1394308e56d7 100644
--- a/docs/releases/5.2.12.txt
+++ b/docs/releases/5.2.12.txt
@@ -9,4 +9,5 @@ Django 5.2.12 fixes one bug related to support for Python 3.14.
Bugfixes
========
-* ...
+* Fixed :exc:`NameError` when inspecting functions making use of deferred
+ annotations in Python 3.14 (:ticket:`36903`).
diff --git a/docs/releases/6.0.3.txt b/docs/releases/6.0.3.txt
index eff44fe9dd6f..ddd853cd49a0 100644
--- a/docs/releases/6.0.3.txt
+++ b/docs/releases/6.0.3.txt
@@ -9,4 +9,5 @@ Django 6.0.3 fixes several bugs in 6.0.2.
Bugfixes
========
-* ...
+* Fixed :exc:`NameError` when inspecting functions making use of deferred
+ annotations in Python 3.14 (:ticket:`36903`).
diff --git a/tests/auth_tests/test_auth_backends.py b/tests/auth_tests/test_auth_backends.py
index 0ba169249b22..3ea6ff6a6955 100644
--- a/tests/auth_tests/test_auth_backends.py
+++ b/tests/auth_tests/test_auth_backends.py
@@ -1,5 +1,7 @@
import sys
+import unittest
from datetime import date
+from typing import TYPE_CHECKING
from unittest import mock
from unittest.mock import patch
@@ -30,6 +32,7 @@
override_settings,
)
from django.urls import reverse
+from django.utils.version import PY314
from django.views.debug import ExceptionReporter, technical_500_response
from django.views.decorators.debug import sensitive_variables
@@ -41,6 +44,10 @@
UUIDUser,
)
+if TYPE_CHECKING:
+ type AnnotatedUsername = str
+ type AnnotatedPassword = str
+
class FilteredExceptionReporter(ExceptionReporter):
def get_traceback_frames(self):
@@ -1285,6 +1292,29 @@ def test_skips_backends_without_arguments(self):
def test_skips_backends_with_decorated_method(self):
self.assertEqual(authenticate(username="test", password="test"), self.user1)
+ @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+ @override_settings(
+ AUTHENTICATION_BACKENDS=[
+ "auth_tests.test_auth_backends.AnnotatedBackend",
+ ],
+ )
+ def test_backend_uses_deferred_annotations(self):
+ class AnnotatedBackend:
+ invariant_user = self.user1
+
+ def authenticate(
+ self,
+ request: HttpRequest,
+ username: AnnotatedUsername,
+ password: AnnotatedPassword,
+ ) -> User | None:
+ return self.invariant_user
+
+ with unittest.mock.patch(
+ "django.contrib.auth.import_string", return_value=AnnotatedBackend
+ ):
+ self.assertEqual(authenticate(username="test", password="test"), self.user1)
+
class ImproperlyConfiguredUserModelTest(TestCase):
"""
diff --git a/tests/check_framework/test_security.py b/tests/check_framework/test_security.py
index d5cc8a9ad69f..0f03845c15b1 100644
--- a/tests/check_framework/test_security.py
+++ b/tests/check_framework/test_security.py
@@ -1,4 +1,6 @@
import itertools
+import unittest
+from typing import TYPE_CHECKING
from django.conf import settings
from django.core.checks.messages import Error, Warning
@@ -6,8 +8,12 @@
from django.core.management.utils import get_random_secret_key
from django.test import SimpleTestCase
from django.test.utils import override_settings
+from django.utils.version import PY314
from django.views.generic import View
+if TYPE_CHECKING:
+ from django.http.request import HttpRequest
+
class CheckSessionCookieSecureTest(SimpleTestCase):
@override_settings(
@@ -613,6 +619,12 @@ def failure_view_with_invalid_signature():
pass
+if PY314:
+
+ def failure_view_with_deferred_annotations(request: HttpRequest, reason: str):
+ pass
+
+
good_class_based_csrf_failure_view = View.as_view()
@@ -653,6 +665,15 @@ def test_failure_view_invalid_signature(self):
def test_failure_view_valid_class_based(self):
self.assertEqual(csrf.check_csrf_failure_view(None), [])
+ @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+ @override_settings(
+ CSRF_FAILURE_VIEW=(
+ "check_framework.test_security.failure_view_with_deferred_annotations"
+ ),
+ )
+ def test_failure_view_valid_deferred_annotations(self):
+ self.assertEqual(csrf.check_csrf_failure_view(None), [])
+
class CheckCrossOriginOpenerPolicyTest(SimpleTestCase):
@override_settings(
diff --git a/tests/check_framework/test_urls.py b/tests/check_framework/test_urls.py
index a3b219f3a489..5164fe7671e8 100644
--- a/tests/check_framework/test_urls.py
+++ b/tests/check_framework/test_urls.py
@@ -1,3 +1,5 @@
+import unittest
+
from django.conf import settings
from django.core.checks.messages import Error, Warning
from django.core.checks.urls import (
@@ -10,6 +12,7 @@
)
from django.test import SimpleTestCase
from django.test.utils import override_settings
+from django.utils.version import PY314
class CheckUrlConfigTests(SimpleTestCase):
@@ -323,6 +326,14 @@ def test_good_function_based_handlers(self):
result = check_custom_error_handlers(None)
self.assertEqual(result, [])
+ @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+ @override_settings(
+ ROOT_URLCONF="check_framework.urls.good_error_handler_deferred_annotations",
+ )
+ def test_good_function_based_handlers_deferred_annotations(self):
+ result = check_custom_error_handlers(None)
+ self.assertEqual(result, [])
+
@override_settings(
ROOT_URLCONF="check_framework.urls.good_class_based_error_handlers",
)
diff --git a/tests/check_framework/urls/good_error_handler_deferred_annotations.py b/tests/check_framework/urls/good_error_handler_deferred_annotations.py
new file mode 100644
index 000000000000..8b4ead556643
--- /dev/null
+++ b/tests/check_framework/urls/good_error_handler_deferred_annotations.py
@@ -0,0 +1,15 @@
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from django.http.request import HttpRequest
+
+urlpatterns = []
+
+handler400 = __name__ + ".good_handler_deferred_annotations"
+handler403 = __name__ + ".good_handler_deferred_annotations"
+handler404 = __name__ + ".good_handler_deferred_annotations"
+handler500 = __name__ + ".good_handler_deferred_annotations"
+
+
+def good_handler_deferred_annotations(request: HttpRequest, exception=None):
+ pass
diff --git a/tests/deprecation/test_deprecate_posargs.py b/tests/deprecation/test_deprecate_posargs.py
index 166fe5dd69ed..8c70d5d20587 100644
--- a/tests/deprecation/test_deprecate_posargs.py
+++ b/tests/deprecation/test_deprecate_posargs.py
@@ -1,7 +1,13 @@
import inspect
+import unittest
+from typing import TYPE_CHECKING
from django.test import SimpleTestCase
from django.utils.deprecation import RemovedAfterNextVersionWarning, deprecate_posargs
+from django.utils.version import PY314
+
+if TYPE_CHECKING:
+ type AnnotatedKwarg = int
class DeprecatePosargsTests(SimpleTestCase):
@@ -355,6 +361,17 @@ def test_decorator_rejects_var_positional_param(self):
def func(*args, b=1):
return args, b
+ @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+ def test_decorator_rejects_var_positional_param_with_deferred_annotation(self):
+ with self.assertRaisesMessage(
+ TypeError,
+ "@deprecate_posargs() cannot be used with variable positional `*args`.",
+ ):
+
+ @deprecate_posargs(RemovedAfterNextVersionWarning, ["b"])
+ def func(*args, b: AnnotatedKwarg = 1):
+ return args, b
+
def test_decorator_does_not_apply_to_class(self):
with self.assertRaisesMessage(
TypeError,
diff --git a/tests/expressions/tests.py b/tests/expressions/tests.py
index 02126fa89612..e495012e079f 100644
--- a/tests/expressions/tests.py
+++ b/tests/expressions/tests.py
@@ -5,6 +5,7 @@
from collections import namedtuple
from copy import deepcopy
from decimal import Decimal
+from typing import TYPE_CHECKING
from unittest import mock
from django.core.exceptions import FieldError
@@ -77,6 +78,7 @@
register_lookup,
)
from django.utils.functional import SimpleLazyObject
+from django.utils.version import PY314
from .models import (
UUID,
@@ -94,6 +96,9 @@
Time,
)
+if TYPE_CHECKING:
+ type AnnotatedKwarg = str
+
class BasicExpressionsTests(TestCase):
@classmethod
@@ -1599,6 +1604,14 @@ def set_source_expressions(self, exprs):
replaced = expression.replace_expressions({"replacement": Expression()})
self.assertEqual(replaced.get_source_expressions(), [falsey])
+ @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+ def test_expression_signature_uses_deferred_annotations(self):
+ class AnnotatedExpression(Expression):
+ def __init__(self, *args, my_kw: AnnotatedKwarg, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.assertEqual(AnnotatedExpression(my_kw=""), AnnotatedExpression(my_kw=""))
+
class ExpressionsNumericTests(TestCase):
@classmethod
diff --git a/tests/template_tests/tests.py b/tests/template_tests/tests.py
index 7364c7ca6462..573fd9db2e2d 100644
--- a/tests/template_tests/tests.py
+++ b/tests/template_tests/tests.py
@@ -1,10 +1,16 @@
import sys
+import unittest
+from typing import TYPE_CHECKING
from django.template import Context, Engine, TemplateDoesNotExist, TemplateSyntaxError
from django.template.base import UNKNOWN_SOURCE
from django.test import SimpleTestCase, override_settings
from django.urls import NoReverseMatch
from django.utils import translation
+from django.utils.version import PY314
+
+if TYPE_CHECKING:
+ type AnnotatedKwarg = str
class TemplateTestMixin:
@@ -221,6 +227,21 @@ def test_render_built_in_type_method(self):
template = self._engine().from_string("{{ description.count }}")
self.assertEqual(template.render(Context({"description": "test"})), "")
+ @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+ def test_callable_uses_deferred_annotations(self):
+ """
+ Missing required arguments are gracefully handled when a signature uses
+ deferred annotations.
+ """
+
+ class MyObject:
+ @staticmethod
+ def uses_deferred_annotations(value: AnnotatedKwarg):
+ return value
+
+ template = self._engine().from_string("{{ obj.uses_deferred_annotations }}")
+ self.assertEqual(template.render(Context({"obj": MyObject()})), "")
+
class TemplateTests(TemplateTestMixin, SimpleTestCase):
debug_engine = False
From 7732f942a98a709750fc1fed2c69741183844a3c Mon Sep 17 00:00:00 2001
From: farhan
Date: Tue, 6 Jan 2026 00:34:39 +0500
Subject: [PATCH 11/11] Fixed #36841 -- Made multipart parser class pluggable
on HttpRequest.
---
django/http/request.py | 18 ++++++++-
docs/ref/request-response.txt | 24 ++++++++++++
docs/releases/6.1.txt | 3 +-
tests/requests_tests/tests.py | 72 ++++++++++++++++++++++++++++++++++-
4 files changed, 114 insertions(+), 3 deletions(-)
diff --git a/django/http/request.py b/django/http/request.py
index c8adde768d23..573ae2b229d6 100644
--- a/django/http/request.py
+++ b/django/http/request.py
@@ -56,6 +56,7 @@ class HttpRequest:
# The encoding used in GET/POST dicts. None means use default setting.
_encoding = None
_upload_handlers = []
+ _multipart_parser_class = MultiPartParser
def __init__(self):
# WARNING: The `WSGIRequest` subclass doesn't call `super`.
@@ -364,6 +365,19 @@ def upload_handlers(self, upload_handlers):
)
self._upload_handlers = upload_handlers
+ @property
+ def multipart_parser_class(self):
+ return self._multipart_parser_class
+
+ @multipart_parser_class.setter
+ def multipart_parser_class(self, multipart_parser_class):
+ if hasattr(self, "_files"):
+ raise RuntimeError(
+ "You cannot set the multipart parser class after the upload has been "
+ "processed."
+ )
+ self._multipart_parser_class = multipart_parser_class
+
def parse_file_upload(self, META, post_data):
"""Return a tuple of (POST QueryDict, FILES MultiValueDict)."""
self.upload_handlers = ImmutableList(
@@ -373,7 +387,9 @@ def parse_file_upload(self, META, post_data):
"processed."
),
)
- parser = MultiPartParser(META, post_data, self.upload_handlers, self.encoding)
+ parser = self.multipart_parser_class(
+ META, post_data, self.upload_handlers, self.encoding
+ )
return parser.parse()
@property
diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt
index 4e3a2015528d..dead7af81380 100644
--- a/docs/ref/request-response.txt
+++ b/docs/ref/request-response.txt
@@ -218,6 +218,30 @@ All attributes should be considered read-only, unless stated otherwise.
executed before URL resolving takes place (you can use it in
:meth:`process_view` though).
+.. attribute:: HttpRequest.multipart_parser_class
+
+ .. versionadded:: 6.1
+
+ The class used to parse ``multipart/form-data`` request data. By default,
+ this is ``django.http.multipartparser.MultiPartParser``.
+
+ You can set this attribute to use a custom multipart parser, either via
+ middleware or directly in views::
+
+ from django.http.multipartparser import MultiPartParser
+
+
+ class CustomMultiPartParser(MultiPartParser):
+ def parse(self):
+ post = QueryDict(mutable=True)
+ files = MultiValueDict()
+ # Custom processing logic here
+ return post, files
+
+
+ # In middleware or view:
+ request.multipart_parser_class = CustomMultiPartParser
+
Attributes set by application code
----------------------------------
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index af783136ad5e..85699324cd8d 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -295,7 +295,8 @@ Pagination
Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~
-* ...
+* :attr:`HttpRequest.multipart_parser_class `
+ can now be customized to use a different multipart parser class.
Security
~~~~~~~~
diff --git a/tests/requests_tests/tests.py b/tests/requests_tests/tests.py
index 36843df9b65b..e52989b0da78 100644
--- a/tests/requests_tests/tests.py
+++ b/tests/requests_tests/tests.py
@@ -13,7 +13,11 @@
RawPostDataException,
UnreadablePostError,
)
-from django.http.multipartparser import MAX_TOTAL_HEADER_SIZE, MultiPartParserError
+from django.http.multipartparser import (
+ MAX_TOTAL_HEADER_SIZE,
+ MultiPartParser,
+ MultiPartParserError,
+)
from django.http.request import split_domain_port
from django.test import RequestFactory, SimpleTestCase, override_settings
from django.test.client import BOUNDARY, MULTIPART_CONTENT, FakePayload
@@ -1112,6 +1116,72 @@ def test_deepcopy(self):
request.session["key"] = "value"
self.assertEqual(request_copy.session, {})
+ def test_custom_multipart_parser_class(self):
+
+ class CustomMultiPartParser(MultiPartParser):
+ def parse(self):
+ post, files = super().parse()
+ post._mutable = True
+ post["custom_parser_used"] = "yes"
+ post._mutable = False
+ return post, files
+
+ class CustomWSGIRequest(WSGIRequest):
+ multipart_parser_class = CustomMultiPartParser
+
+ payload = FakePayload(
+ "\r\n".join(
+ [
+ "--boundary",
+ 'Content-Disposition: form-data; name="name"',
+ "",
+ "value",
+ "--boundary--",
+ ]
+ )
+ )
+ request = CustomWSGIRequest(
+ {
+ "REQUEST_METHOD": "POST",
+ "CONTENT_TYPE": "multipart/form-data; boundary=boundary",
+ "CONTENT_LENGTH": len(payload),
+ "wsgi.input": payload,
+ }
+ )
+ self.assertEqual(request.POST.get("custom_parser_used"), "yes")
+ self.assertEqual(request.POST.get("name"), "value")
+
+ def test_multipart_parser_class_immutable_after_parse(self):
+ payload = FakePayload(
+ "\r\n".join(
+ [
+ "--boundary",
+ 'Content-Disposition: form-data; name="name"',
+ "",
+ "value",
+ "--boundary--",
+ ]
+ )
+ )
+ request = WSGIRequest(
+ {
+ "REQUEST_METHOD": "POST",
+ "CONTENT_TYPE": "multipart/form-data; boundary=boundary",
+ "CONTENT_LENGTH": len(payload),
+ "wsgi.input": payload,
+ }
+ )
+
+ # Access POST to trigger parsing.
+ request.POST
+
+ msg = (
+ "You cannot set the multipart parser class after the upload has been "
+ "processed."
+ )
+ with self.assertRaisesMessage(RuntimeError, msg):
+ request.multipart_parser_class = MultiPartParser
+
class HostValidationTests(SimpleTestCase):
poisoned_hosts = [