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/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 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/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/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/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/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/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 `. 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/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/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/5.2.12.txt b/docs/releases/5.2.12.txt new file mode 100644 index 000000000000..1394308e56d7 --- /dev/null +++ b/docs/releases/5.2.12.txt @@ -0,0 +1,13 @@ +=========================== +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 +======== + +* 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/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. diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 0d3598254381..85699324cd8d 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 ~~~~~~~~~~ @@ -292,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/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 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: 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", diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index 330859bcf438..a36574d4dfb9 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( @@ -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/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/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, ) ) 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/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/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/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/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/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/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/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/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/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/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):
  • """ % (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): '

    ' '' - '

    ' + '

    ' % author2.id, ) self.assertHTMLEqual( @@ -242,7 +242,7 @@ def test_simple_save(self): '

    ' '' - '

    ' + '

    ' % author1.id, ) self.assertHTMLEqual( @@ -292,7 +292,7 @@ def test_simple_save(self): 'value="Arthur Rimbaud" maxlength="100">

    ' '

    ' '' - '

    ' + '

    ' % author2.id, ) self.assertHTMLEqual( @@ -302,7 +302,7 @@ def test_simple_save(self): 'value="Charles Baudelaire" maxlength="100">

    ' '

    ' '' - '

    ' + '

    ' % author1.id, ) self.assertHTMLEqual( @@ -312,7 +312,7 @@ def test_simple_save(self): 'value="Paul Verlaine" maxlength="100">

    ' '

    ' '' - '

    ' + '

    ' % author3.id, ) self.assertHTMLEqual( @@ -604,7 +604,7 @@ def test_model_inheritance(self): '

    ' '' - '

    ' % hemingway_id, ) self.assertHTMLEqual( @@ -649,7 +649,7 @@ def test_inline_formsets(self): '

    ' '' - '' '' "

    " % author.id, @@ -659,7 +659,7 @@ def test_inline_formsets(self): '

    ' '' - '' '

    ' % author.id, @@ -669,7 +669,7 @@ def test_inline_formsets(self): '

    ' '' - '' '

    ' % author.id, @@ -709,9 +709,9 @@ def test_inline_formsets(self): '

    ' '' - '' - '

    ' % ( author.id, @@ -723,7 +723,7 @@ def test_inline_formsets(self): '

    ' '' - '' '

    ' % author.id, @@ -733,7 +733,7 @@ def test_inline_formsets(self): '

    ' '' - '' '

    ' % 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): '

    ' '

    " '

    ' '

    ' @@ -1289,7 +1289,7 @@ def test_custom_pk(self): '

    ' '' - '

    ' % owner1.auto_id, ) @@ -1315,7 +1315,7 @@ def test_custom_pk(self): '

    ' '' - '

    ' % owner1.auto_id, ) @@ -1589,7 +1589,7 @@ def test_callable_defaults(self): '

    ' '' - '' '

    ' % 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): '" % (band2.id, self.band.id), ) @@ -661,7 +661,7 @@ class ConcertAdminWithForm(ModelAdmin): '" % self.band.id, ) 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 = [ 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, 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 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