# Fixed #36943 -- Preserved original URLconf exception in autoreloader.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number. -->
<!-- Or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36943

#### Branch description
Earlier, the autoreloader used to hide exceptions raised while loading the URLConf.

This change preserves the original exception using exception chaining

#### AI Assistance Disclosure (REQUIRED)
<!-- Please select exactly ONE of the following: -->
- [x] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period (see [guidelines](https://docs.djangoproject.com/en/dev/internals/contributing/committing-code/#committing-guidelines)).
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/utils_tests/test_autoreload.py b/tests/utils_tests/test_autoreload.py
index c9e6443c6fbf..2033728da809 100644
--- a/tests/utils_tests/test_autoreload.py
+++ b/tests/utils_tests/test_autoreload.py
@@ -434,6 +434,18 @@ def test_mutates_error_files(self):
                 autoreload._exception = None
         self.assertEqual(mocked_error_files.append.call_count, 1)

+    def test_urlconf_exception_is_used_as_cause(self):
+        urlconf_exc = ValueError("Error")
+        fake_method = mock.MagicMock(side_effect=RuntimeError())
+        wrapped = autoreload.check_errors(fake_method)
+        with mock.patch.object(autoreload, "_url_module_exception", urlconf_exc):
+            try:
+                with self.assertRaises(RuntimeError) as cm:
+                    wrapped()
+            finally:
+                autoreload._exception = None
+        self.assertIs(cm.exception.__cause__, urlconf_exc)
+

 class TestRaiseLastException(SimpleTestCase):
     @mock.patch("django.utils.autoreload._exception", None)

diff --git a/django/utils/autoreload.py b/django/utils/autoreload.py
index 99812979d73c..13019f7214b7 100644
--- a/django/utils/autoreload.py
+++ b/django/utils/autoreload.py
@@ -33,6 +33,8 @@
 # file paths to allow watching them in the future.
 _error_files = []
 _exception = None
+# Exception raised while loading the URLConf.
+_url_module_exception = None

 try:
     import termios
@@ -62,7 +64,7 @@ def wrapper(*args, **kwargs):
         global _exception
         try:
             fn(*args, **kwargs)
-        except Exception:
+        except Exception as e:
             _exception = sys.exc_info()

             et, ev, tb = _exception
@@ -75,8 +77,10 @@ def wrapper(*args, **kwargs):

             if filename not in _error_files:
                 _error_files.append(filename)
+            if _url_module_exception is not None:
+                raise e from _url_module_exception

-            raise
+            raise e

     return wrapper

@@ -339,6 +343,7 @@ def wait_for_apps_ready(self, app_reg, django_main_thread):
             return False

     def run(self, django_main_thread):
+        global _url_module_exception
         logger.debug("Waiting for apps ready_event.")
         self.wait_for_apps_ready(apps, django_main_thread)
         from django.urls import get_resolver
@@ -347,10 +352,10 @@ def run(self, django_main_thread):
         # reloader starts by accessing the urlconf_module property.
         try:
             get_resolver().urlconf_module
-        except Exception:
+        except Exception as e:
             # Loading the urlconf can result in errors during development.
-            # If this occurs then swallow the error and continue.
-            pass
+            # If this occurs then store the error and continue.
+            _url_module_exception = e
         logger.debug("Apps ready_event triggered. Sending autoreload_started signal.")
         autoreload_started.send(sender=self)
         self.run_loop()


# Doc'd return values of as_sql() for Func and query expressions.

Mostly to make it clear that `params` may be a list or tuple.

diff --git a/tests/migrations/migrations_test_apps/unmigrated_app_syncdb/models.py b/tests/migrations/migrations_test_apps/unmigrated_app_syncdb/models.py
index 9f3179cd0d6d..c539b20d1184 100644
--- a/tests/migrations/migrations_test_apps/unmigrated_app_syncdb/models.py
+++ b/tests/migrations/migrations_test_apps/unmigrated_app_syncdb/models.py
@@ -7,3 +7,8 @@ class Classroom(models.Model):

 class Lesson(models.Model):
     classroom = models.ForeignKey(Classroom, on_delete=models.CASCADE)
+
+
+class VeryLongNameModel(models.Model):
+    class Meta:
+        db_table = "long_db_table_that_should_be_truncated_before_checking"
diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py
index 6a0d9bd6d28d..e9929c1eafa9 100644
--- a/tests/migrations/test_commands.py
+++ b/tests/migrations/test_commands.py
@@ -25,6 +25,7 @@
     connections,
     models,
 )
+from django.db.backends.base.introspection import BaseDatabaseIntrospection
 from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 from django.db.backends.utils import truncate_name
 from django.db.migrations.autodetector import MigrationAutodetector
@@ -1222,10 +1223,10 @@ def test_migrate_syncdb_deferred_sql_executed_with_schemaeditor(self):
             create_table_count = len(
                 [call for call in execute.mock_calls if "CREATE TABLE" in str(call)]
             )
-            self.assertEqual(create_table_count, 2)
+            self.assertEqual(create_table_count, 3)
             # There's at least one deferred SQL for creating the foreign key
             # index.
-            self.assertGreater(len(execute.mock_calls), 2)
+            self.assertGreater(len(execute.mock_calls), 3)
         stdout = stdout.getvalue()
         self.assertIn("Synchronize unmigrated apps: unmigrated_app_syncdb", stdout)
         self.assertIn("Creating tables...", stdout)
@@ -1259,8 +1260,54 @@ def test_migrate_syncdb_app_label(self):
             create_table_count = len(
                 [call for call in execute.mock_calls if "CREATE TABLE" in str(call)]
             )
-            self.assertEqual(create_table_count, 2)
+            self.assertEqual(create_table_count, 3)
+            self.assertGreater(len(execute.mock_calls), 3)
+            self.assertIn(
+                "Synchronize unmigrated app: unmigrated_app_syncdb", stdout.getvalue()
+            )
+
+    @override_settings(
+        INSTALLED_APPS=[
+            "migrations.migrations_test_apps.unmigrated_app_syncdb",
+            "migrations.migrations_test_apps.unmigrated_app_simple",
+        ]
+    )
+    def test_migrate_syncdb_installed_truncated_db_model(self):
+        """
+        Running migrate --run-syncdb doesn't try to create models with long
+        truncated name if already exist.
+        """
+        with connection.cursor() as cursor:
+            mock_existing_tables = connection.introspection.table_names(cursor)
+        # Add truncated name for the VeryLongNameModel to the list of
+        # existing table names.
+        table_name = truncate_name(
+            "long_db_table_that_should_be_truncated_before_checking",
+            connection.ops.max_name_length(),
+        )
+        mock_existing_tables.append(table_name)
+        stdout = io.StringIO()
+        with (
+            mock.patch.object(BaseDatabaseSchemaEditor, "execute") as execute,
+            mock.patch.object(
+                BaseDatabaseIntrospection,
+                "table_names",
+                return_value=mock_existing_tables,
+            ),
+        ):
+            call_command(
+                "migrate", "unmigrated_app_syncdb", run_syncdb=True, stdout=stdout
+            )
+            create_table_calls = [
+                str(call).upper()
+                for call in execute.mock_calls
+                if "CREATE TABLE" in str(call)
+            ]
+            self.assertEqual(len(create_table_calls), 2)
             self.assertGreater(len(execute.mock_calls), 2)
+            self.assertFalse(
+                any([table_name.upper() in call for call in create_table_calls])
+            )
             self.assertIn(
                 "Synchronize unmigrated app: unmigrated_app_syncdb", stdout.getvalue()
             )

diff --git a/django/core/management/commands/migrate.py b/django/core/management/commands/migrate.py
index 268f669ba257..62ad29e43d8a 100644
--- a/django/core/management/commands/migrate.py
+++ b/django/core/management/commands/migrate.py
@@ -6,6 +6,7 @@
 from django.core.management.base import BaseCommand, CommandError, no_translations
 from django.core.management.sql import emit_post_migrate_signal, emit_pre_migrate_signal
 from django.db import DEFAULT_DB_ALIAS, connections, router
+from django.db.backends.utils import truncate_name
 from django.db.migrations.autodetector import MigrationAutodetector
 from django.db.migrations.executor import MigrationExecutor
 from django.db.migrations.loader import AmbiguityError
@@ -447,8 +448,9 @@ def sync_apps(self, connection, app_labels):
         def model_installed(model):
             opts = model._meta
             converter = connection.introspection.identifier_converter
+            max_name_length = connection.ops.max_name_length()
             return not (
-                (converter(opts.db_table) in tables)
+                (converter(truncate_name(opts.db_table, max_name_length)) in tables)
                 or (
                     opts.auto_created
                     and converter(opts.auto_created._meta.db_table) in tables


# Fixed #36940 -- Improved ASGI script prefix path_info handling.

#### Trac ticket number

ticket-36940

#### Branch description

The current `ASGIRequest.__init__` uses `str.removeprefix()` to strip the script name from the request path to compute `path_info`. This is fragile because `removeprefix` is a pure string operation — it doesn't verify that the prefix is a proper path segment boundary.

For example, if `script_name` is `/myapp` and the path is `/myapplication/page`, `removeprefix` would incorrectly produce `lication/page`.

This patch replaces `removeprefix` with a check that ensures the script name is followed by `/` or is the exact path, before stripping it. This addresses the inline TODO comment.

#### AI Assistance Disclosure (REQUIRED)
- [x] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

AI tools (Claude) were used to understand the issue and guide the approach. The code was reviewed and verified manually.

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch.
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/handlers/tests.py b/tests/handlers/tests.py
index 83dfd95713b8..625090a66d0c 100644
--- a/tests/handlers/tests.py
+++ b/tests/handlers/tests.py
@@ -346,6 +346,26 @@ def test_force_script_name(self):
         self.assertEqual(request.script_name, "/FORCED_PREFIX")
         self.assertEqual(request.path_info, "/somepath/")

+    def test_root_path_prefix_boundary(self):
+        async_request_factory = AsyncRequestFactory()
+        # When path shares a textual prefix with root_path but not at a
+        # segment boundary, path_info should be the full path.
+        request = async_request_factory.request(
+            **{"path": "/rootprefix/somepath/", "root_path": "/root"}
+        )
+        self.assertEqual(request.path, "/rootprefix/somepath/")
+        self.assertEqual(request.script_name, "/root")
+        self.assertEqual(request.path_info, "/rootprefix/somepath/")
+
+    def test_root_path_trailing_slash(self):
+        async_request_factory = AsyncRequestFactory()
+        request = async_request_factory.request(
+            **{"path": "/root/somepath/", "root_path": "/root/"}
+        )
+        self.assertEqual(request.path, "/root/somepath/")
+        self.assertEqual(request.script_name, "/root/")
+        self.assertEqual(request.path_info, "/somepath/")
+
     async def test_sync_streaming(self):
         response = await self.async_client.get("/streaming/")
         self.assertEqual(response.status_code, 200)

diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
index c8118e1691f9..9555860a7e21 100644
--- a/django/core/handlers/asgi.py
+++ b/django/core/handlers/asgi.py
@@ -54,10 +54,13 @@ def __init__(self, scope, body_file):
         self.path = scope["path"]
         self.script_name = get_script_prefix(scope)
         if self.script_name:
-            # TODO: Better is-prefix checking, slash handling?
-            self.path_info = scope["path"].removeprefix(self.script_name)
+            script_name = self.script_name.rstrip("/")
+            if self.path.startswith(script_name + "/") or self.path == script_name:
+                self.path_info = self.path[len(script_name) :]
+            else:
+                self.path_info = self.path
         else:
-            self.path_info = scope["path"]
+            self.path_info = self.path
         # HTTP basics.
         self.method = self.scope["method"].upper()
         # Ensure query string is encoded correctly.


# Fixed #36968 -- Improved error message when collectstatic can't find a referenced file.

#### Trac ticket number
ticket-36968

#### Branch description
A suggestion of how we could improve the message that is shown to people when the file referenced in a css/js is missing.
Wraps the ValueError for missing file when it comes from collectstatic in a 'CommandError' and updated the message to include a little more information. The CommandError will hide the traceback by default and it can still be accessed by providing --traceback

Example

Vs Old

Open to feedback on wording. It's inspired by [whitenoise](https://github.com/evansd/whitenoise/blob/13997326100d37bc0b9edab01c886dea85bc05c3/src/whitenoise/storage.py#L190C18-L190C34)
The intent is to make it easier for people to find where the issue is in their files when things go wrong.
But where there is a suggestion on what to look for in whitenoise, I felt it was safer to give the person the information of the file missing, the file and line it was found in, and leave it for them to fix it from there.

It could be a number of things that need fixing, from most to least likely in my experience
1) An error in your code i.ie. a typo, wrong folder, or actual missing file
2) external lib references file you don't have/need, e.g. sourcemap file
3) confusion over relative paths and how storage resolves files
4) storage is not configured correctly and not picking up files you expect it to

Trying to call out any one of these could mislead people, calling out them all is too long, and it's probably not exhaustive anyway.

#### AI Assistance Disclosure (REQUIRED)

- [X] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [X] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [X] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [X] This PR targets the `main` branch.
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [X] I have checked the "Has patch" ticket flag in the Trac system.
- [X] I have added or updated relevant tests.
- [X] I have added or updated relevant docs, including release notes if applicable. N/A
- [X] I have attached screenshots in both light and dark modes for any UI changes. N/A

diff --git a/tests/staticfiles_tests/test_storage.py b/tests/staticfiles_tests/test_storage.py
index cdb6fd3c7e2e..9db449bf9df2 100644
--- a/tests/staticfiles_tests/test_storage.py
+++ b/tests/staticfiles_tests/test_storage.py
@@ -13,7 +13,7 @@
 from django.contrib.staticfiles.management.commands.collectstatic import (
     Command as CollectstaticCommand,
 )
-from django.core.management import call_command
+from django.core.management import CommandError, call_command
 from django.test import SimpleTestCase, override_settings

 from .cases import CollectionTestCase
@@ -201,8 +201,10 @@ def test_template_tag_url(self):
     def test_import_loop(self):
         finders.get_finder.cache_clear()
         err = StringIO()
-        with self.assertRaisesMessage(RuntimeError, "Max post-process passes exceeded"):
+        msg = "Max post-process passes exceeded"
+        with self.assertRaisesMessage(CommandError, msg) as cm:
             call_command("collectstatic", interactive=False, verbosity=0, stderr=err)
+        self.assertIsInstance(cm.exception.__cause__, RuntimeError)
         self.assertEqual(
             "Post-processing 'bar.css, foo.css' failed!\n\n", err.getvalue()
         )
@@ -367,9 +369,14 @@ def test_post_processing_failure(self):
         """
         finders.get_finder.cache_clear()
         err = StringIO()
-        with self.assertRaises(Exception):
+        with self.assertRaises(CommandError) as cm:
             call_command("collectstatic", interactive=False, verbosity=0, stderr=err)
         self.assertEqual("Post-processing 'faulty.css' failed!\n\n", err.getvalue())
+        self.assertIsInstance(cm.exception.__cause__, ValueError)
+        exc_message = str(cm.exception)
+        self.assertIn("faulty.css", exc_message)
+        self.assertIn("missing.css", exc_message)
+        self.assertIn("1:", exc_message)  # line 1 reported
         self.assertPostCondition()

     @override_settings(
@@ -379,8 +386,9 @@ def test_post_processing_failure(self):
     def test_post_processing_nonutf8(self):
         finders.get_finder.cache_clear()
         err = StringIO()
-        with self.assertRaises(UnicodeDecodeError):
+        with self.assertRaises(CommandError) as cm:
             call_command("collectstatic", interactive=False, verbosity=0, stderr=err)
+        self.assertIsInstance(cm.exception.__cause__, UnicodeDecodeError)
         self.assertEqual("Post-processing 'nonutf8.css' failed!\n\n", err.getvalue())
         self.assertPostCondition()

diff --git a/django/contrib/staticfiles/management/commands/collectstatic.py b/django/contrib/staticfiles/management/commands/collectstatic.py
index 1c3f8ba26d53..6fcff46d93af 100644
--- a/django/contrib/staticfiles/management/commands/collectstatic.py
+++ b/django/contrib/staticfiles/management/commands/collectstatic.py
@@ -154,7 +154,11 @@ def collect(self):
                     # Add a blank line before the traceback, otherwise it's
                     # too easy to miss the relevant part of the error message.
                     self.stderr.write()
-                    raise processed
+                    # Re-raise exceptions as CommandError and display notes.
+                    message = str(processed)
+                    if hasattr(processed, "__notes__"):
+                        message += "\n" + "\n".join(processed.__notes__)
+                    raise CommandError(message) from processed
                 if processed:
                     self.log(
                         "Post-processed '%s' as '%s'" % (original_path, processed_path),
diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py
index c889bcb4a495..e0af40638423 100644
--- a/django/contrib/staticfiles/storage.py
+++ b/django/contrib/staticfiles/storage.py
@@ -232,6 +232,16 @@ def url_converter(self, name, hashed_files, template=None, comment_blocks=None):
         if template is None:
             template = self.default_template

+        def _line_at_position(content, position):
+            start = content.rfind("\n", 0, position) + 1
+            end = content.find("\n", position)
+            end = end if end != -1 else len(content)
+            line_num = content.count("\n", 0, start) + 1
+            msg = f"\n{line_num}: {content[start:end]}"
+            if len(msg) > 79:
+                return f"\n{line_num}"
+            return msg
+
         def converter(matchobj):
             """
             Convert the matched URL to a normalized and hashed URL.
@@ -276,12 +286,18 @@ def converter(matchobj):

             # Determine the hashed name of the target file with the storage
             # backend.
-            hashed_url = self._url(
-                self._stored_name,
-                unquote(target_name),
-                force=True,
-                hashed_files=hashed_files,
-            )
+            try:
+                hashed_url = self._url(
+                    self._stored_name,
+                    unquote(target_name),
+                    force=True,
+                    hashed_files=hashed_files,
+                )
+            except ValueError as exc:
+                line = _line_at_position(matchobj.string, matchobj.start())
+                note = f"{name!r} contains this reference {matched!r} on line {line}"
+                exc.add_note(note)
+                raise exc

             transformed_url = "/".join(
                 url_path.split("/")[:-1] + hashed_url.split("/")[-1:]


# Fixed #36679 -- Fixed Basque date formats to use parenthetical declension suffixes.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number. -->
<!-- Or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36679

#### Branch description
Basque (eu) grammar requires conditional suffixes on years and day articles that depend on the final sound of the preceding word. Since Django's format strings are static, the CLDR parenthetical convention ("(e)ko" instead of "ko") is used to express the optionality.

This is a revamp of #19988.

#### AI Assistance Disclosure (REQUIRED)
<!-- Please select exactly ONE of the following: -->
- [ ] **No AI tools were used** in preparing this PR.
- [X] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

Claude code with model Sonnet 4.6 was used to analyze and summarize the [forum post](https://forum.djangoproject.com/t/basque-date-declination/43205) and [related ticket](https://code.djangoproject.com/ticket/36679).

#### Checklist
- [X] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [X] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [X] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [X] I have checked the "Has patch" ticket flag in the Trac system.
- [X] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py
index e593d97cba14..91e7c95f67a0 100644
--- a/tests/i18n/tests.py
+++ b/tests/i18n/tests.py
@@ -1179,6 +1179,26 @@ def test_uncommon_locale_formats(self):
                 with translation.override(locale, deactivate=True):
                     self.assertEqual(expected, format_function(*format_args))

+    def test_basque_date_formats(self):
+        # Basque locale uses parenthetical suffixes for conditional declension:
+        # (e)ko for years and declined month/day forms.
+        with translation.override("eu", deactivate=True):
+            self.assertEqual(date_format(self.d), "2009(e)ko abe.k 31")
+            self.assertEqual(
+                date_format(self.dt, "DATETIME_FORMAT"),
+                "2009(e)ko abe.k 31, 20:50",
+            )
+            self.assertEqual(
+                date_format(self.d, "YEAR_MONTH_FORMAT"), "2009(e)ko abendua"
+            )
+            self.assertEqual(date_format(self.d, "MONTH_DAY_FORMAT"), "abenduaren 31a")
+            # Day 11 (hamaika in Basque) ends in 'a' as a word, but the
+            # numeral form does not, so appending 'a' is correct here.
+            self.assertEqual(
+                date_format(datetime.date(2009, 12, 11), "MONTH_DAY_FORMAT"),
+                "abenduaren 11a",
+            )
+
     def test_sub_locales(self):
         """
         Check if sublocales fall back to the main locale

diff --git a/django/conf/locale/eu/formats.py b/django/conf/locale/eu/formats.py
index 61b16fbc6f69..e707f931c70c 100644
--- a/django/conf/locale/eu/formats.py
+++ b/django/conf/locale/eu/formats.py
@@ -2,10 +2,10 @@
 #
 # The *_FORMAT strings use the Django date format syntax,
 # see https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
-DATE_FORMAT = r"Y\k\o N j\a"
+DATE_FORMAT = r"Y(\e)\k\o N\k j"
 TIME_FORMAT = "H:i"
-DATETIME_FORMAT = r"Y\k\o N j\a, H:i"
-YEAR_MONTH_FORMAT = r"Y\k\o F"
+DATETIME_FORMAT = r"Y(\e)\k\o N\k j, H:i"
+YEAR_MONTH_FORMAT = r"Y(\e)\k\o F"
 MONTH_DAY_FORMAT = r"F\r\e\n j\a"
 SHORT_DATE_FORMAT = "Y-m-d"
 SHORT_DATETIME_FORMAT = "Y-m-d H:i"


# Refs #28877 -- Added special ordinal context when humanizing value 1.

In french we need to specialize 1st which does not work like 81st, it's 1er and 81ième. For zero is tricky but it's attested that some use 0ième.

Also fixed 101 that was tested to be `101er` while in french it's `101e` (sourced in my commit).

#### Checklist
- [x] This PR targets the `main` branch.
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/humanize_tests/tests.py b/tests/humanize_tests/tests.py
index 7c2863d3c478..91e9127a65bf 100644
--- a/tests/humanize_tests/tests.py
+++ b/tests/humanize_tests/tests.py
@@ -1,4 +1,5 @@
 import datetime
+import os
 from decimal import Decimal

 from django.contrib.humanize.templatetags import humanize
@@ -9,10 +10,10 @@
 from django.utils.timezone import get_fixed_timezone
 from django.utils.translation import gettext as _

+here = os.path.dirname(os.path.abspath(__file__))
 # Mock out datetime in some tests so they don't fail occasionally when they
 # run too slow. Use a fixed datetime for datetime.now(). DST change in
 # America/Chicago (the default time zone) happened on March 11th in 2012.
-
 now = datetime.datetime(2012, 3, 9, 22, 30)

@@ -83,6 +84,7 @@ def test_ordinal(self):
         with translation.override("en"):
             self.humanize_tester(test_list, result_list, "ordinal")

+    @override_settings(LOCALE_PATHS=[os.path.join(here, "locale")])
     def test_i18n_html_ordinal(self):
         """Allow html in output on i18n strings"""
         test_list = (
@@ -93,6 +95,8 @@ def test_i18n_html_ordinal(self):
             "11",
             "12",
             "13",
+            "21",
+            "31",
             "101",
             "102",
             "103",
@@ -108,7 +112,9 @@ def test_i18n_html_ordinal(self):
             "11e",
             "12e",
             "13e",
-            "101er",
+            "21e",
+            "31e",
+            "101e",
             "102e",
             "103e",
             "111e",

diff --git a/django/contrib/humanize/templatetags/humanize.py b/django/contrib/humanize/templatetags/humanize.py
index 26a9dd3a3f68..79d2e1566721 100644
--- a/django/contrib/humanize/templatetags/humanize.py
+++ b/django/contrib/humanize/templatetags/humanize.py
@@ -32,7 +32,10 @@ def ordinal(value):
         return value
     if value < 0:
         return str(value)
-    if value % 100 in (11, 12, 13):
+    if value == 1:
+        # Translators: Ordinal format when value is 1 (1st).
+        value = pgettext("ordinal is 1", "{}st").format(value)
+    elif value % 100 in (11, 12, 13):
         # Translators: Ordinal format for 11 (11th), 12 (12th), and 13 (13th).
         value = pgettext("ordinal 11, 12, 13", "{}th").format(value)
     else:


# Fixed #21080 -- Ignored urls inside comments during collectstatic.

#### Trac ticket number
ticket-21080

#### Branch description
Follows on from the work in https://github.com/django/django/pull/11241/ by @tmszi and @n6g7, it ignores block comments in css/js files when considering url subustitutions. It also adds line // comments for javascript. This does not attempt to solve the wider problems in javascript around matches in strings. But comments will cover a lot if incremental cases.

#### AI Assistance Disclosure (REQUIRED)
<!-- Please select exactly ONE of the following: -->
- [X] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [X] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [X] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [X] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [X] I have checked the "Has patch" ticket flag in the Trac system.
- [X] I have added or updated relevant tests.
- [X] I have added or updated relevant docs, including release notes if applicable.
- [X] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/staticfiles_tests/test_storage.py b/tests/staticfiles_tests/test_storage.py
index e09f9eda1c90..cdb6fd3c7e2e 100644
--- a/tests/staticfiles_tests/test_storage.py
+++ b/tests/staticfiles_tests/test_storage.py
@@ -65,7 +65,7 @@ def test_template_tag_simple_content(self):

     def test_path_ignored_completely(self):
         relpath = self.hashed_file_path("cached/css/ignored.css")
-        self.assertEqual(relpath, "cached/css/ignored.55e7c226dda1.css")
+        self.assertEqual(relpath, "cached/css/ignored.0e15ac4a4fb4.css")
         with storage.staticfiles_storage.open(relpath) as relfile:
             content = relfile.read()
             self.assertIn(b"#foobar", content)
@@ -75,6 +75,22 @@ def test_path_ignored_completely(self):
             self.assertIn(b"chrome:foobar", content)
             self.assertIn(b"//foobar", content)
             self.assertIn(b"url()", content)
+            self.assertIn(b'/* @import url("non_exist.css") */', content)
+            self.assertIn(b'/* url("non_exist.png") */', content)
+            self.assertIn(b'@import url("non_exist.css")', content)
+            self.assertIn(b'url("non_exist.png")', content)
+            self.assertIn(b"@import url(other.css)", content)
+            self.assertIn(
+                b'background: #d3d6d8 /*url("does.not.exist.png")*/ '
+                b'url("/static/cached/img/relative.acae32e4532b.png");',
+                content,
+            )
+            self.assertIn(
+                b'background: #d3d6d8 /* url("does.not.exist.png") */ '
+                b'url("/static/cached/img/relative.acae32e4532b.png") '
+                b'/*url("does.not.exist.either.png")*/',
+                content,
+            )
         self.assertPostCondition()

     def test_path_with_querystring(self):
@@ -698,7 +714,7 @@ class TestCollectionJSModuleImportAggregationManifestStorage(CollectionTestCase)

     def test_module_import(self):
         relpath = self.hashed_file_path("cached/module.js")
-        self.assertEqual(relpath, "cached/module.4326210cf0bd.js")
+        self.assertEqual(relpath, "cached/module.eaa407b94311.js")
         tests = [
             # Relative imports.
             b'import testConst from "./module_test.477bbebe77f0.js";',
@@ -721,6 +737,15 @@ def test_module_import(self):
             b"    firstVar1 as firstVarAlias,\n"
             b"    $second_var_2 as secondVarAlias\n"
             b'} from "./module_test.477bbebe77f0.js";',
+            # Ignore block comments
+            b'/* export * from "./module_test_missing.js"; */',
+            b"/*\n"
+            b'import rootConst from "/static/absolute_root_missing.js";\n'
+            b'const dynamicModule = import("./module_test_missing.js");\n'
+            b"*/",
+            # Ignore line comments
+            b'// import testConst from "./module_test_missing.js";',
+            b'// const dynamicModule = import("./module_test_missing.js");',
         ]
         with storage.staticfiles_storage.open(relpath) as relfile:
             content = relfile.read()
@@ -730,7 +755,7 @@ def test_module_import(self):

     def test_aggregating_modules(self):
         relpath = self.hashed_file_path("cached/module.js")
-        self.assertEqual(relpath, "cached/module.4326210cf0bd.js")
+        self.assertEqual(relpath, "cached/module.eaa407b94311.js")
         tests = [
             b'export * from "./module_test.477bbebe77f0.js";',
             b'export { testConst } from "./module_test.477bbebe77f0.js";',

diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py
index b16c77757cd6..c889bcb4a495 100644
--- a/django/contrib/staticfiles/storage.py
+++ b/django/contrib/staticfiles/storage.py
@@ -11,6 +11,12 @@
 from django.core.files.base import ContentFile
 from django.core.files.storage import FileSystemStorage, storages
 from django.utils.functional import LazyObject
+from django.utils.regex_helper import _lazy_re_compile
+
+comment_re = _lazy_re_compile(r"\/\*[^*]*\*+([^/*][^*]*\*+)*\/", re.DOTALL)
+line_comment_re = _lazy_re_compile(
+    r"\/\*[^*]*\*+([^/*][^*]*\*+)*\/|\/\/[^\n]*", re.DOTALL
+)

 class StaticFilesStorage(FileSystemStorage):
@@ -204,7 +210,22 @@ def url(self, name, force=False):
         """
         return self._url(self.stored_name, name, force)

-    def url_converter(self, name, hashed_files, template=None):
+    def get_comment_blocks(self, content, include_line_comments=False):
+        """
+        Return a list of (start, end) tuples for each comment block.
+        """
+        pattern = line_comment_re if include_line_comments else comment_re
+        return [(match.start(), match.end()) for match in re.finditer(pattern, content)]
+
+    def is_in_comment(self, pos, comments):
+        for start, end in comments:
+            if start < pos and pos < end:
+                return True
+            if pos < start:
+                return False
+        return False
+
+    def url_converter(self, name, hashed_files, template=None, comment_blocks=None):
         """
         Return the custom URL converter for the given file name.
         """
@@ -222,6 +243,10 @@ def converter(matchobj):
             matched = matches["matched"]
             url = matches["url"]

+            # Ignore URLs in comments.
+            if comment_blocks and self.is_in_comment(matchobj.start(), comment_blocks):
+                return matched
+
             # Ignore absolute/protocol-relative and data-uri URLs.
             if re.match(r"^[a-z]+:", url) or url.startswith("//"):
                 return matched
@@ -375,7 +400,13 @@ def path_level(name):
                         if matches_patterns(path, (extension,)):
                             for pattern, template in patterns:
                                 converter = self.url_converter(
-                                    name, hashed_files, template
+                                    name,
+                                    hashed_files,
+                                    template,
+                                    self.get_comment_blocks(
+                                        content,
+                                        include_line_comments=path.endswith(".js"),
+                                    ),
                                 )
                                 try:
                                     content = pattern.sub(converter, content)


# Fixed #36961 -- Fixed TypeError in deprecation warnings if Django is imported by namespace.

#### Trac ticket number
ticket-36961

#### Branch description
Found while testing #20300 -- I opened a shell, and tried to create a `Field.get_placeholder` method, but because my cwd was one level above my Django checkout, I had:
```py
>>> django.__file__ is None
True
```

Leading to a TypeError in this util.

#### AI Assistance Disclosure (REQUIRED)
- [x] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [x] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/deprecation/tests.py b/tests/deprecation/tests.py
index 2df9cc6fa219..ac610b492835 100644
--- a/tests/deprecation/tests.py
+++ b/tests/deprecation/tests.py
@@ -18,6 +18,10 @@ def setUp(self):
     def test_no_file(self):
         orig_file = django.__file__
         try:
+            # Depending on the cwd, Python might give a local checkout
+            # precedence over installed Django, producing None.
+            django.__file__ = None
+            self.assertEqual(django_file_prefixes(), ())
             del django.__file__
             self.assertEqual(django_file_prefixes(), ())
         finally:

diff --git a/django/utils/deprecation.py b/django/utils/deprecation.py
index daa485eb35bf..4aa11832165e 100644
--- a/django/utils/deprecation.py
+++ b/django/utils/deprecation.py
@@ -13,9 +13,8 @@

 @functools.cache
 def django_file_prefixes():
-    try:
-        file = django.__file__
-    except AttributeError:
+    file = getattr(django, "__file__", None)
+    if file is None:
         return ()
     return (os.path.dirname(file),)


# Refs #35972 -- Returned params in a tuple in further expressions.

#### Trac ticket number
ticket-35972

#### Branch description
As [pointed out](https://github.com/django/django/pull/20300#discussion_r2643723457) by Simon, `Value.as_sql` still returned params in a list.

#### AI Assistance Disclosure (REQUIRED)
- [x] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/expressions/tests.py b/tests/expressions/tests.py
index cb62d0fbd73f..5effc8ac0d17 100644
--- a/tests/expressions/tests.py
+++ b/tests/expressions/tests.py
@@ -2504,9 +2504,9 @@ def test_compile_unresolved(self):
         # This test might need to be revisited later on if #25425 is enforced.
         compiler = Time.objects.all().query.get_compiler(connection=connection)
         value = Value("foo")
-        self.assertEqual(value.as_sql(compiler, connection), ("%s", ["foo"]))
+        self.assertEqual(value.as_sql(compiler, connection), ("%s", ("foo",)))
         value = Value("foo", output_field=CharField())
-        self.assertEqual(value.as_sql(compiler, connection), ("%s", ["foo"]))
+        self.assertEqual(value.as_sql(compiler, connection), ("%s", ("foo",)))

     def test_output_field_decimalfield(self):
         Time.objects.create()

diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py
index 63d0c1802b49..d0e91f13d278 100644
--- a/django/db/models/expressions.py
+++ b/django/db/models/expressions.py
@@ -769,7 +769,7 @@ def as_sql(self, compiler, connection):
         # order of precedence
         expression_wrapper = "(%s)"
         sql = connection.ops.combine_expression(self.connector, expressions)
-        return expression_wrapper % sql, expression_params
+        return expression_wrapper % sql, tuple(expression_params)

     def resolve_expression(
         self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False
@@ -835,7 +835,7 @@ def as_sql(self, compiler, connection):
         # order of precedence
         expression_wrapper = "(%s)"
         sql = connection.ops.combine_duration_expression(self.connector, expressions)
-        return expression_wrapper % sql, expression_params
+        return expression_wrapper % sql, tuple(expression_params)

     def as_sqlite(self, compiler, connection, **extra_context):
         sql, params = self.as_sql(compiler, connection, **extra_context)
@@ -1179,8 +1179,8 @@ def as_sql(self, compiler, connection):
             # oracledb does not always convert None to the appropriate
             # NULL type (like in case expressions using numbers), so we
             # use a literal SQL NULL
-            return "NULL", []
-        return "%s", [val]
+            return "NULL", ()
+        return "%s", (val,)

     def as_sqlite(self, compiler, connection, **extra_context):
         sql, params = self.as_sql(compiler, connection, **extra_context)
@@ -1273,7 +1273,7 @@ def __repr__(self):
         return "'*'"

     def as_sql(self, compiler, connection):
-        return "*", []
+        return "*", ()

 class DatabaseDefault(Expression):
@@ -1313,7 +1313,7 @@ def resolve_expression(
     def as_sql(self, compiler, connection):
         if not connection.features.supports_default_keyword_in_insert:
             return compiler.compile(self.expression)
-        return "DEFAULT", []
+        return "DEFAULT", ()

 class Col(Expression):
@@ -1398,7 +1398,7 @@ def as_sql(self, compiler, connection):
             cols_sql.append(sql)
             cols_params.extend(params)

-        return ", ".join(cols_sql), cols_params
+        return ", ".join(cols_sql), tuple(cols_params)

     def relabeled_clone(self, relabels):
         return self.__class__(
@@ -1447,7 +1447,7 @@ def relabeled_clone(self, relabels):
         return clone

     def as_sql(self, compiler, connection):
-        return connection.ops.quote_name(self.refs), []
+        return connection.ops.quote_name(self.refs), ()

     def get_group_by_cols(self):
         return [self]
@@ -1764,7 +1764,7 @@ def as_sql(
         sql = template % template_params
         if self._output_field_or_none is not None:
             sql = connection.ops.unification_cast_sql(self.output_field) % sql
-        return sql, sql_params
+        return sql, tuple(sql_params)

     def get_group_by_cols(self):
         if not self.cases:
@@ -2148,7 +2148,7 @@ def as_sql(self, compiler, connection):
                 "end": end,
                 "exclude": self.get_exclusion(),
             },
-            [],
+            (),
         )

     def __repr__(self):


# Fixed #36750 -- Made ordering of M2M objects deterministic in serializers.

#### Trac ticket number

ticket-36750

#### Branch description

Following the implementation of `QuerySet.totally_ordered` in ticket [#36857](https://code.djangoproject.com/ticket/36857), this patch updates the Python and XML serializers to apply deterministic ordering for many-to-many relations using natural keys. The serializers check ``qs.totally_ordered`` and, when the ``queryset`` lacks a total ordering, they extract any existing ordering and simply append ``'pk'`` to break ties. This preserves any default model sorting while guaranteeing determinism, addressing the inconsistent fixture output on PostgreSQL without imposing redundant sorting.
 A regression test in `tests/fixtures/tests.py` verifies this behavior for both formats.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/serializers/test_natural.py b/tests/serializers/test_natural.py
index b405dc3e284b..4dff100cc7af 100644
--- a/tests/serializers/test_natural.py
+++ b/tests/serializers/test_natural.py
@@ -1,5 +1,7 @@
+from unittest import mock
+
 from django.core import serializers
-from django.db import connection
+from django.db import connection, models
 from django.test import TestCase

 from .models import (
@@ -336,6 +338,20 @@ def nullable_natural_key_m2m_test(self, format):
     )

+def natural_key_m2m_totally_ordered_test(self, format):
+    t1 = NaturalKeyThing.objects.create(key="t1")
+    t2 = NaturalKeyThing.objects.create(key="t2")
+    t3 = NaturalKeyThing.objects.create(key="t3")
+    t1.other_things.add(t2, t3)
+
+    with mock.patch.object(models.QuerySet, "order_by") as mock_order_by:
+        serializers.serialize(format, [t1], use_natural_foreign_keys=True)
+        mock_order_by.assert_called_once_with("pk")
+        mock_order_by.reset_mock()
+        serializers.serialize(format, [t1], use_natural_foreign_keys=False)
+        mock_order_by.assert_called_once_with("pk")
+
+
 # Dynamically register tests for each serializer
 register_tests(
     NaturalKeySerializerTests,
@@ -385,3 +401,8 @@ def nullable_natural_key_m2m_test(self, format):
     "test_%s_nullable_natural_key_m2m",
     nullable_natural_key_m2m_test,
 )
+register_tests(
+    NaturalKeySerializerTests,
+    "test_%s_natural_key_m2m_totally_ordered",
+    natural_key_m2m_totally_ordered_test,
+)

diff --git a/django/core/serializers/python.py b/django/core/serializers/python.py
index 53a73e19e51f..73ba24368b0b 100644
--- a/django/core/serializers/python.py
+++ b/django/core/serializers/python.py
@@ -77,7 +77,15 @@ def queryset_iterator(obj, field):
                     chunk_size = (
                         2000 if getattr(attr, "prefetch_cache_name", None) else None
                     )
-                    return attr.iterator(chunk_size)
+                    query_set = attr.all()
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
+                    return query_set.iterator(chunk_size)

             else:

@@ -86,6 +94,13 @@ def m2m_value(value):

                 def queryset_iterator(obj, field):
                     query_set = getattr(obj, field.name).select_related(None).only("pk")
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
                     chunk_size = 2000 if query_set._prefetch_related_lookups else None
                     return query_set.iterator(chunk_size=chunk_size)

diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py
index d8ffbdf00a98..3d1f79ea0d00 100644
--- a/django/core/serializers/xml_serializer.py
+++ b/django/core/serializers/xml_serializer.py
@@ -177,7 +177,15 @@ def queryset_iterator(obj, field):
                     chunk_size = (
                         2000 if getattr(attr, "prefetch_cache_name", None) else None
                     )
-                    return attr.iterator(chunk_size)
+                    query_set = attr.all()
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
+                    return query_set.iterator(chunk_size)

             else:

@@ -186,6 +194,13 @@ def handle_m2m(value):

                 def queryset_iterator(obj, field):
                     query_set = getattr(obj, field.name).select_related(None).only("pk")
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
                     chunk_size = 2000 if query_set._prefetch_related_lookups else None
                     return query_set.iterator(chunk_size=chunk_size)


# Fixed #36951 -- Removed empty exc_info from log_task_finished signal handler.

#### Trac ticket number
N/A

#### Branch description

If no exception occurred, the logger in `django.tasks.signals.log_task_finished` would print `None Type: None`

Before:

	Task id=191 path=tests.dummy.tasks.dummy_recurring_task state=RUNNING
	dummy recurring task
	Task id=191 path=tests.dummy.tasks.dummy_recurring_task state=SUCCESSFUL
	NoneType: None

After:

	Task id=193 path=tests.dummy.tasks.dummy_recurring_task state=RUNNING
	dummy recurring task
	Task id=193 path=tests.dummy.tasks.dummy_recurring_task state=SUCCESSFUL

Of course, if there is an exception, it is still printed:

	Task id=195 path=tests.dummy.tasks.dummy_recurring_task state=RUNNING
	Traceback (most recent call last):
	  File "/Users/elias/dev/steady_queue/steady_queue/models/claimed_execution.py", line 93, in perform
	    task.func(*args, **kwargs)
	    ~~~~~~~~~^^^^^^^^^^^^^^^^^
	  File "/Users/elias/dev/steady_queue/tests/dummy/tasks.py", line 53, in dummy_recurring_task
	    return 1 / 0
		   ~~^~~
	ZeroDivisionError: division by zero

#### AI Assistance Disclosure (REQUIRED)

- [ ] **No AI tools were used** in preparing this PR.
- [x] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

AI tools used: `cursor-agent` with `Claude Opus 4.6 (thinking)`.

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/tasks/test_immediate_backend.py b/tests/tasks/test_immediate_backend.py
index 356e9ab2649d..36b63faff801 100644
--- a/tests/tasks/test_immediate_backend.py
+++ b/tests/tasks/test_immediate_backend.py
@@ -228,6 +228,15 @@ def test_failed_logs(self):
         self.assertIn("state=FAILED", captured_logs.output[2])
         self.assertIn(result.id, captured_logs.output[2])

+    def test_successful_task_no_none_in_logs(self):
+        with self.assertLogs("django.tasks", level="DEBUG") as captured_logs:
+            result = test_tasks.noop_task.enqueue()
+
+        self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
+
+        for log_output in captured_logs.output:
+            self.assertNotIn("None", log_output)
+
     def test_takes_context(self):
         result = test_tasks.get_task_id.enqueue()

diff --git a/django/tasks/signals.py b/django/tasks/signals.py
index 288fe08e328a..919dae022221 100644
--- a/django/tasks/signals.py
+++ b/django/tasks/signals.py
@@ -49,6 +49,8 @@ def log_task_started(sender, task_result, **kwargs):

 @receiver(task_finished)
 def log_task_finished(sender, task_result, **kwargs):
+    # Signal is sent inside exception handlers, so exc_info() is available.
+    exc_info = sys.exc_info()
     logger.log(
         (
             logging.ERROR
@@ -59,6 +61,5 @@ def log_task_finished(sender, task_result, **kwargs):
         task_result.id,
         task_result.task.module_path,
         task_result.status,
-        # Signal is sent inside exception handlers, so exc_info() is available.
-        exc_info=sys.exc_info(),
+        exc_info=exc_info if exc_info[0] else None,
     )


# Fixed #36944 -- Removed MAX_LENGTH_HTML and related 5M chars limit references from HTML truncation docs.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number. -->
<!-- Or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36944

#### Branch description
Following up some security reports, we noticed the docs around HTML length limit enforcement was outdated. This work cleans that up.

#### AI Assistance Disclosure (REQUIRED)
<!-- Please select exactly ONE of the following: -->
- [X] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [X] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [X] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [X] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [X] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [X] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py
index 11c01874cb5d..50e205a25449 100644
--- a/tests/utils_tests/test_text.py
+++ b/tests/utils_tests/test_text.py
@@ -1,6 +1,5 @@
 import json
 import sys
-from unittest.mock import patch

 from django.core.exceptions import SuspiciousFileOperation
 from django.test import SimpleTestCase
@@ -136,23 +135,6 @@ def test_truncate_chars_html(self):
         truncator = text.Truncator("foo
")
         self.assertEqual("foo
", truncator.chars(5, html=True))

-    @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
-    def test_truncate_chars_html_size_limit(self):
-        max_len = text.Truncator.MAX_LENGTH_HTML
-        bigger_len = text.Truncator.MAX_LENGTH_HTML + 1
-        valid_html = "
Joel is a slug
"  # 14 chars
-        perf_test_values = [
-            ("", ""),
-            ("
", "
"),
-            ("&" * bigger_len, ""),
-            ("_X<<<<<<<<<<<>", "_X<<<<<<<…"),
-            (valid_html * bigger_len, "
Joel is a…
"),  # 10 chars
-        ]
-        for value, expected in perf_test_values:
-            with self.subTest(value=value):
-                truncator = text.Truncator(value)
-                self.assertEqual(expected, truncator.chars(10, html=True))
-
     def test_truncate_chars_html_with_newline_inside_tag(self):
         truncator = text.Truncator(
             '
The quick brown fox jumped over '
@@ -329,24 +311,6 @@ def test_truncate_html_words(self):
         self.assertEqual(truncator.words(3, html=True), "hello ><…")
         self.assertEqual(truncator.words(4, html=True), "hello >< world")

-    @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
-    def test_truncate_words_html_size_limit(self):
-        max_len = text.Truncator.MAX_LENGTH_HTML
-        bigger_len = text.Truncator.MAX_LENGTH_HTML + 1
-        valid_html = "
Joel is a slug
"  # 4 words
-        perf_test_values = [
-            ("", ""),
-            ("
", "
"),
-            ("&" * max_len, ""),
-            ("&" * bigger_len, ""),
-            ("_X<<<<<<<<<<<>", "_X<<<<<<<<<<<>"),
-            (valid_html * bigger_len, valid_html * 12 + "
Joel is…
"),  # 50 words
-        ]
-        for value, expected in perf_test_values:
-            with self.subTest(value=value):
-                truncator = text.Truncator(value)
-                self.assertEqual(expected, truncator.words(50, html=True))
-
     def test_wrap(self):
         digits = "1234 67 9"
         self.assertEqual(text.wrap(digits, 100), "1234 67 9")

diff --git a/django/utils/text.py b/django/utils/text.py
index ef4baa935bf2..cfe6ceca9e4b 100644
--- a/django/utils/text.py
+++ b/django/utils/text.py
@@ -185,14 +185,8 @@ def process(self, data):
 class Truncator(SimpleLazyObject):
     """
     An object used to truncate text, either by characters or words.
-
-    When truncating HTML text (either chars or words), input will be limited to
-    at most `MAX_LENGTH_HTML` characters.
     """

-    # 5 million characters are approximately 4000 text pages or 3 web pages.
-    MAX_LENGTH_HTML = 5_000_000
-
     def __init__(self, text):
         super().__init__(lambda: str(text))


# Fixed #36839 -- Warned when model renames encounter conflicts from stale ContentTypes.

#### Trac ticket number

ticket-36839

#### Branch description

Fixed #36839 -- Warn when ContentType rename conflicts with stale entries.

When renaming a model, Django automatically injects a `RenameContentType` operation to update the content type. If a stale content type with the target name already exists, the operation catches the `IntegrityError` and silently reverts without notifying the user. This results in the migration appearing successful while the content type rename actually failed, leading to broken GenericForeignKey references and stale data.

This change adds a `RuntimeWarning` when the rename conflict occurs, informing users to run the `remove_stale_contenttypes` management command to resolve the issue. The existing fallback behavior is preserved—no data is deleted or overwritten automatically.

#### AI Assistance Disclosure (REQUIRED)

- [ ] **No AI tools were used** in preparing this PR.
- [x] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

Claude Sonnet 4.5 was used for assistance with adding tests. All output was thoroughly reviewed and verified by me.

#### Checklist

- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch.
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/contenttypes_tests/test_operations.py b/tests/contenttypes_tests/test_operations.py
index d44648d9fe6e..9f6506640ad6 100644
--- a/tests/contenttypes_tests/test_operations.py
+++ b/tests/contenttypes_tests/test_operations.py
@@ -154,13 +154,19 @@ def test_missing_content_type_rename_ignore(self):
     def test_content_type_rename_conflict(self):
         ContentType.objects.create(app_label="contenttypes_tests", model="foo")
         ContentType.objects.create(app_label="contenttypes_tests", model="renamedfoo")
-        call_command(
-            "migrate",
-            "contenttypes_tests",
-            database="default",
-            interactive=False,
-            verbosity=0,
-        )
+        msg = (
+            "Could not rename content type 'contenttypes_tests.foo' to "
+            "'renamedfoo' due to an existing conflicting content type. "
+            "Run 'remove_stale_contenttypes' to clean up stale entries."
+        )
+        with self.assertWarnsMessage(RuntimeWarning, msg):
+            call_command(
+                "migrate",
+                "contenttypes_tests",
+                database="default",
+                interactive=False,
+                verbosity=0,
+            )
         self.assertTrue(
             ContentType.objects.filter(
                 app_label="contenttypes_tests", model="foo"
@@ -171,14 +177,20 @@ def test_content_type_rename_conflict(self):
                 app_label="contenttypes_tests", model="renamedfoo"
             ).exists()
         )
-        call_command(
-            "migrate",
-            "contenttypes_tests",
-            "zero",
-            database="default",
-            interactive=False,
-            verbosity=0,
-        )
+        msg = (
+            "Could not rename content type 'contenttypes_tests.renamedfoo' to "
+            "'foo' due to an existing conflicting content type. "
+            "Run 'remove_stale_contenttypes' to clean up stale entries."
+        )
+        with self.assertWarnsMessage(RuntimeWarning, msg):
+            call_command(
+                "migrate",
+                "contenttypes_tests",
+                "zero",
+                database="default",
+                interactive=False,
+                verbosity=0,
+            )
         self.assertTrue(
             ContentType.objects.filter(
                 app_label="contenttypes_tests", model="foo"

diff --git a/django/contrib/contenttypes/management/__init__.py b/django/contrib/contenttypes/management/__init__.py
index 929e44f390db..55b08870d83e 100644
--- a/django/contrib/contenttypes/management/__init__.py
+++ b/django/contrib/contenttypes/management/__init__.py
@@ -1,3 +1,5 @@
+import warnings
+
 from django.apps import apps as global_apps
 from django.db import DEFAULT_DB_ALIAS, IntegrityError, migrations, router, transaction

@@ -28,8 +30,14 @@ def _rename(self, apps, schema_editor, old_model, new_model):
                     content_type.save(using=db, update_fields={"model"})
             except IntegrityError:
                 # Gracefully fallback if a stale content type causes a
-                # conflict as remove_stale_contenttypes will take care of
-                # asking the user what should be done next.
+                # conflict. Warn the user so they can run the
+                # remove_stale_contenttypes management command.
+                warnings.warn(
+                    f"Could not rename content type '{self.app_label}.{old_model}' "
+                    f"to '{new_model}' due to an existing conflicting content type. "
+                    "Run 'remove_stale_contenttypes' to clean up stale entries.",
+                    RuntimeWarning,
+                )
                 content_type.model = old_model
             else:
                 # Clear the cache as the `get_by_natural_key()` call will cache


# Fixed #36921 -- Fixed KeyError when adding inline instances of models not registered with admin.

#### Trac ticket number
ticket-36921

#### Branch description
Added an if statement to avoid key error if model not registered in admin.

Note this bug was introduced in another pull request I worked on [here](https://github.com/django/django/pull/18934/).

I added the example Jacob shared to the same example project that had been used with that issue [here](https://github.com/reergymerej/ticket_13883).

#### AI Assistance Disclosure (REQUIRED)
- [ ] **No AI tools were used** in preparing this PR.
- [x] **If AI tools were used** - Claude Code plugin in VS Code

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [x] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 7f35d7c2f455..fa9d9a2dc6f5 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -589,6 +589,31 @@ def test_popup_add_POST_with_invalid_source_model(self):
         self.assertIn("admin_views.nonexistent", str(messages[0]))
         self.assertIn("could not be found", str(messages[0]))

+    def test_popup_add_POST_with_unregistered_source_model(self):
+        """
+        Popup add where source_model is a valid Django model but is not
+        registered in the admin site (e.g. a model only used as an inline)
+        should succeed without raising a KeyError.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            # Chapter exists as a model but is not registered in site (only
+            # in site6), simulating a model used only as an inline.
+            SOURCE_MODEL_VAR: "admin_views.chapter",
+            "title": "Test Article",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        response = self.client.post(reverse("admin:admin_views_article_add"), post_data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "data-popup-response")
+        # No error messages - unregistered model is silently skipped.
+        messages = list(response.wsgi_request._messages)
+        self.assertEqual(len(messages), 0)
+        # No optgroup in the response.
+        self.assertNotContains(response, ""optgroup"")
+
     def test_basic_edit_POST(self):
         """
         A smoke test to ensure POST on edit_view works.

diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 9c787d232912..b67b023bd313 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -1419,7 +1419,7 @@ def response_add(self, request, obj, post_url_continue=None):

             # Find the optgroup for the new item, if available
             source_model_name = request.POST.get(SOURCE_MODEL_VAR)
-
+            source_admin = None
             if source_model_name:
                 app_label, model_name = source_model_name.split(".", 1)
                 try:
@@ -1428,21 +1428,23 @@ def response_add(self, request, obj, post_url_continue=None):
                     msg = _('The app "%s" could not be found.') % source_model_name
                     self.message_user(request, msg, messages.ERROR)
                 else:
-                    source_admin = self.admin_site._registry[source_model]
-                    form = source_admin.get_form(request)()
-                    if self.opts.verbose_name_plural in form.fields:
-                        field = form.fields[self.opts.verbose_name_plural]
-                        for option_value, option_label in field.choices:
-                            # Check if this is an optgroup (label is a sequence
-                            # of choices rather than a single string value).
-                            if isinstance(option_label, (list, tuple)):
-                                # It's an optgroup:
-                                # (group_name, [(value, label), ...])
-                                optgroup_label = option_value
-                                for choice_value, choice_display in option_label:
-                                    if choice_display == str(obj):
-                                        popup_response["optgroup"] = str(optgroup_label)
-                                        break
+                    source_admin = self.admin_site._registry.get(source_model)
+
+            if source_admin:
+                form = source_admin.get_form(request)()
+                if self.opts.verbose_name_plural in form.fields:
+                    field = form.fields[self.opts.verbose_name_plural]
+                    for option_value, option_label in field.choices:
+                        # Check if this is an optgroup (label is a sequence
+                        # of choices rather than a single string value).
+                        if isinstance(option_label, (list, tuple)):
+                            # It's an optgroup:
+                            # (group_name, [(value, label), ...])
+                            optgroup_label = option_value
+                            for choice_value, choice_display in option_label:
+                                if choice_display == str(obj):
+                                    popup_response["optgroup"] = str(optgroup_label)
+                                    break

             popup_response_data = json.dumps(popup_response)
             return TemplateResponse(


# Fixed #36890 -- Supported StringAgg(distinct=True) on SQLite with the default delimiter.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number. -->
<!-- Or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36890

#### Branch description
Currently, the generic StringAgg function raises an error on SQLite when distinct=True

This change enables support for distinct aggregation on SQLite by omitting the delimiter argument in the generated SQL when it matches the default comma.

#### AI Assistance Disclosure (REQUIRED)
<!-- Please select exactly ONE of the following: -->
- [ ] **No AI tools were used** in preparing this PR.
- [X] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

I used GitHub Copilot to assist with writing tests.

#### Checklist
- [X] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [X] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [X] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [X] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

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/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,


# Fixed #36841 -- Made multipart parser class pluggable on HttpRequest.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36841

#### Branch description
Provide a concise overview of the issue or rationale behind the proposed changes.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

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/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


# Refs #36036 -- Added m dimension to GEOSCoordSeq.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36036

#### Branch description
As per the suggestion in https://github.com/django/django/pull/19013#issuecomment-2816751388 this adds M dimension support to `coordseq`.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

diff --git a/tests/gis_tests/geos_tests/test_coordseq.py b/tests/gis_tests/geos_tests/test_coordseq.py
index b6f5136dd19c..37c421f5c3b8 100644
--- a/tests/gis_tests/geos_tests/test_coordseq.py
+++ b/tests/gis_tests/geos_tests/test_coordseq.py
@@ -1,4 +1,11 @@
-from django.contrib.gis.geos import LineString
+import math
+from unittest import skipIf
+from unittest.mock import patch
+
+from django.contrib.gis.geos import GEOSGeometry, LineString
+from django.contrib.gis.geos import prototypes as capi
+from django.contrib.gis.geos.coordseq import GEOSCoordSeq
+from django.contrib.gis.geos.libgeos import geos_version_tuple
 from django.test import SimpleTestCase

@@ -13,3 +20,130 @@ def test_getitem(self):
             with self.subTest(i):
                 with self.assertRaisesMessage(IndexError, msg):
                     coord_seq[i]
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_has_m(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertIs(coord_seq.hasm, True)
+
+        geom = GEOSGeometry("POINT Z (1 2 3)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertIs(coord_seq.hasm, False)
+
+        geom = GEOSGeometry("POINT M (1 2 3)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertIs(coord_seq.hasm, True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_get_set_m(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.tuple, (1, 2, 3, 4))
+        self.assertEqual(coord_seq.getM(0), 4)
+        coord_seq.setM(0, 10)
+        self.assertEqual(coord_seq.tuple, (1, 2, 3, 10))
+        self.assertEqual(coord_seq.getM(0), 10)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.tuple, (1, 2, 4))
+        self.assertEqual(coord_seq.getM(0), 4)
+        coord_seq.setM(0, 10)
+        self.assertEqual(coord_seq.tuple, (1, 2, 10))
+        self.assertEqual(coord_seq.getM(0), 10)
+        self.assertIs(math.isnan(coord_seq.getZ(0)), True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_setitem(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        coord_seq[0] = (10, 20, 30, 40)
+        self.assertEqual(coord_seq.tuple, (10, 20, 30, 40))
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        coord_seq[0] = (10, 20, 40)
+        self.assertEqual(coord_seq.tuple, (10, 20, 40))
+        self.assertEqual(coord_seq.getM(0), 40)
+        self.assertIs(math.isnan(coord_seq.getZ(0)), True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_kml_m_dimension(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.kml, "<coordinates>1.0,2.0,3.0</coordinates>")
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.kml, "<coordinates>1.0,2.0,0</coordinates>")
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_clone_m_dimension(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        clone = coord_seq.clone()
+        self.assertEqual(clone.tuple, (1, 2, 3, 4))
+        self.assertIs(clone.hasz, True)
+        self.assertIs(clone.hasm, True)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        clone = coord_seq.clone()
+        self.assertEqual(clone.tuple, (1, 2, 4))
+        self.assertIs(clone.hasz, False)
+        self.assertIs(clone.hasm, True)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_dims(self):
+        geom = GEOSGeometry("POINT ZM (1 2 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.dims, 4)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.dims, 3)
+
+        geom = GEOSGeometry("POINT Z (1 2 3)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(coord_seq.dims, 3)
+
+        geom = GEOSGeometry("POINT (1 2)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.dims, 2)
+
+    def test_size(self):
+        geom = GEOSGeometry("POINT (1 2)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.size, 1)
+
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False)
+        self.assertEqual(coord_seq.size, 1)
+
+    @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
+    def test_iscounterclockwise(self):
+        geom = GEOSGeometry("LINEARRING ZM (0 0 3 0, 1 0 0 2, 0 1 1 3, 0 0 3 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        self.assertEqual(
+            coord_seq.tuple,
+            (
+                (0.0, 0.0, 3.0, 0.0),
+                (1.0, 0.0, 0.0, 2.0),
+                (0.0, 1.0, 1.0, 3.0),
+                (0.0, 0.0, 3.0, 4.0),
+            ),
+        )
+        self.assertIs(coord_seq.is_counterclockwise, True)
+
+    def test_m_support_error(self):
+        geom = GEOSGeometry("POINT M (1 2 4)")
+        coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True)
+        msg = "GEOSCoordSeq with an M dimension requires GEOS 3.14+."
+
+        # mock geos_version_tuple to be 3.13.13
+        with patch(
+            "django.contrib.gis.geos.coordseq.geos_version_tuple",
+            return_value=(3, 13, 13),
+        ):
+            with self.assertRaisesMessage(NotImplementedError, msg):
+                coord_seq.hasm

diff --git a/django/contrib/gis/geos/coordseq.py b/django/contrib/gis/geos/coordseq.py
index dec3495d25c9..febeeebfa3bc 100644
--- a/django/contrib/gis/geos/coordseq.py
+++ b/django/contrib/gis/geos/coordseq.py
@@ -9,7 +9,7 @@
 from django.contrib.gis.geos import prototypes as capi
 from django.contrib.gis.geos.base import GEOSBase
 from django.contrib.gis.geos.error import GEOSException
-from django.contrib.gis.geos.libgeos import CS_PTR
+from django.contrib.gis.geos.libgeos import CS_PTR, geos_version_tuple
 from django.contrib.gis.shortcuts import numpy

@@ -20,6 +20,8 @@ class GEOSCoordSeq(GEOSBase):

     def __init__(self, ptr, z=False):
         "Initialize from a GEOS pointer."
+        # TODO when dropping support for GEOS 3.13 the z argument can be
+        # deprecated in favor of using the GEOS function GEOSCoordSeq_hasZ.
         if not isinstance(ptr, CS_PTR):
             raise TypeError("Coordinate sequence should initialize with a CS_PTR.")
         self._ptr = ptr
@@ -58,6 +60,12 @@ def __setitem__(self, index, value):
         if self.dims == 3 and self._z:
             n_args = 3
             point_setter = self._set_point_3d
+        elif self.dims == 3 and self.hasm:
+            n_args = 3
+            point_setter = self._set_point_3d_m
+        elif self.dims == 4 and self._z and self.hasm:
+            n_args = 4
+            point_setter = self._set_point_4d
         else:
             n_args = 2
             point_setter = self._set_point_2d
@@ -74,7 +82,7 @@ def _checkindex(self, index):

     def _checkdim(self, dim):
         "Check the given dimension."
-        if dim < 0 or dim > 2:
+        if dim < 0 or dim > 3:
             raise GEOSException(f'Invalid ordinate dimension: "{dim:d}"')

     def _get_x(self, index):
@@ -86,6 +94,9 @@ def _get_y(self, index):
     def _get_z(self, index):
         return capi.cs_getz(self.ptr, index, byref(c_double()))

+    def _get_m(self, index):
+        return capi.cs_getm(self.ptr, index, byref(c_double()))
+
     def _set_x(self, index, value):
         capi.cs_setx(self.ptr, index, value)

@@ -95,9 +106,18 @@ def _set_y(self, index, value):
     def _set_z(self, index, value):
         capi.cs_setz(self.ptr, index, value)

+    def _set_m(self, index, value):
+        capi.cs_setm(self.ptr, index, value)
+
     @property
     def _point_getter(self):
-        return self._get_point_3d if self.dims == 3 and self._z else self._get_point_2d
+        if self.dims == 3 and self._z:
+            return self._get_point_3d
+        elif self.dims == 3 and self.hasm:
+            return self._get_point_3d_m
+        elif self.dims == 4 and self._z and self.hasm:
+            return self._get_point_4d
+        return self._get_point_2d

     def _get_point_2d(self, index):
         return (self._get_x(index), self._get_y(index))
@@ -105,6 +125,17 @@ def _get_point_2d(self, index):
     def _get_point_3d(self, index):
         return (self._get_x(index), self._get_y(index), self._get_z(index))

+    def _get_point_3d_m(self, index):
+        return (self._get_x(index), self._get_y(index), self._get_m(index))
+
+    def _get_point_4d(self, index):
+        return (
+            self._get_x(index),
+            self._get_y(index),
+            self._get_z(index),
+            self._get_m(index),
+        )
+
     def _set_point_2d(self, index, value):
         x, y = value
         self._set_x(index, x)
@@ -116,6 +147,19 @@ def _set_point_3d(self, index, value):
         self._set_y(index, y)
         self._set_z(index, z)

+    def _set_point_3d_m(self, index, value):
+        x, y, m = value
+        self._set_x(index, x)
+        self._set_y(index, y)
+        self._set_m(index, m)
+
+    def _set_point_4d(self, index, value):
+        x, y, z, m = value
+        self._set_x(index, x)
+        self._set_y(index, y)
+        self._set_z(index, z)
+        self._set_m(index, m)
+
     # #### Ordinate getting and setting routines ####
     def getOrdinate(self, dimension, index):
         "Return the value for the given dimension and index."
@@ -153,6 +197,14 @@ def setZ(self, index, value):
         "Set Z with the value at the given index."
         self.setOrdinate(2, index, value)

+    def getM(self, index):
+        "Get M with the value at the given index."
+        return self.getOrdinate(3, index)
+
+    def setM(self, index, value):
+        "Set M with the value at the given index."
+        self.setOrdinate(3, index, value)
+
     # ### Dimensions ###
     @property
     def size(self):
@@ -172,6 +224,18 @@ def hasz(self):
         """
         return self._z

+    @property
+    def hasm(self):
+        """
+        Return whether this coordinate sequence has M dimension.
+        """
+        if geos_version_tuple() >= (3, 14):
+            return capi.cs_hasm(self._ptr)
+        else:
+            raise NotImplementedError(
+                "GEOSCoordSeq with an M dimension requires GEOS 3.14+."
+            )
+
     # ### Other Methods ###
     def clone(self):
         "Clone this coordinate sequence."
@@ -180,16 +244,13 @@ def clone(self):
     @property
     def kml(self):
         "Return the KML representation for the coordinates."
-        # Getting the substitution string depending on whether the coordinates
-        # have a Z dimension.
         if self.hasz:
-            substr = "%s,%s,%s "
+            coords = [f"{coord[0]},{coord[1]},{coord[2]}" for coord in self]
         else:
-            substr = "%s,%s,0 "
-        return (
-            "<coordinates>%s</coordinates>"
-            % "".join(substr % self[i] for i in range(len(self))).strip()
-        )
+            coords = [f"{coord[0]},{coord[1]},0" for coord in self]
+
+        coordinate_string = " ".join(coords)
+        return f"<coordinates>{coordinate_string}</coordinates>"

     @property
     def tuple(self):
diff --git a/django/contrib/gis/geos/prototypes/__init__.py b/django/contrib/gis/geos/prototypes/__init__.py
index 6b0da37ee676..cac0b3fdf46b 100644
--- a/django/contrib/gis/geos/prototypes/__init__.py
+++ b/django/contrib/gis/geos/prototypes/__init__.py
@@ -8,12 +8,15 @@
     create_cs,
     cs_clone,
     cs_getdims,
+    cs_getm,
     cs_getordinate,
     cs_getsize,
     cs_getx,
     cs_gety,
     cs_getz,
+    cs_hasm,
     cs_is_ccw,
+    cs_setm,
     cs_setordinate,
     cs_setx,
     cs_sety,
diff --git a/django/contrib/gis/geos/prototypes/coordseq.py b/django/contrib/gis/geos/prototypes/coordseq.py
index cfc242c00dd1..eadaf8dfcf1e 100644
--- a/django/contrib/gis/geos/prototypes/coordseq.py
+++ b/django/contrib/gis/geos/prototypes/coordseq.py
@@ -1,7 +1,15 @@
 from ctypes import POINTER, c_byte, c_double, c_int, c_uint

-from django.contrib.gis.geos.libgeos import CS_PTR, GEOM_PTR, GEOSFuncFactory
-from django.contrib.gis.geos.prototypes.errcheck import GEOSException, last_arg_byref
+from django.contrib.gis.geos.libgeos import (
+    CS_PTR,
+    GEOM_PTR,
+    GEOSFuncFactory,
+)
+from django.contrib.gis.geos.prototypes.errcheck import (
+    GEOSException,
+    check_predicate,
+    last_arg_byref,
+)

 # ## Error-checking routines specific to coordinate sequences. ##
@@ -67,6 +75,12 @@ def errcheck(result, func, cargs):
         return result

+class CsUnaryPredicate(GEOSFuncFactory):
+    argtypes = [CS_PTR]
+    restype = c_byte
+    errcheck = staticmethod(check_predicate)
+
+
 # ## Coordinate Sequence ctypes prototypes ##

 # Coordinate Sequence constructors & cloning.
@@ -78,20 +92,25 @@ def errcheck(result, func, cargs):
 cs_getordinate = CsOperation("GEOSCoordSeq_getOrdinate", ordinate=True, get=True)
 cs_setordinate = CsOperation("GEOSCoordSeq_setOrdinate", ordinate=True)

-# For getting, x, y, z
+# For getting, x, y, z, m
 cs_getx = CsOperation("GEOSCoordSeq_getX", get=True)
 cs_gety = CsOperation("GEOSCoordSeq_getY", get=True)
 cs_getz = CsOperation("GEOSCoordSeq_getZ", get=True)
+cs_getm = CsOperation("GEOSCoordSeq_getM", get=True)

-# For setting, x, y, z
+# For setting, x, y, z, m
 cs_setx = CsOperation("GEOSCoordSeq_setX")
 cs_sety = CsOperation("GEOSCoordSeq_setY")
 cs_setz = CsOperation("GEOSCoordSeq_setZ")
+cs_setm = CsOperation("GEOSCoordSeq_setM")

 # These routines return size & dimensions.
 cs_getsize = CsInt("GEOSCoordSeq_getSize")
 cs_getdims = CsInt("GEOSCoordSeq_getDimensions")

+# Unary Predicates
+cs_hasm = CsUnaryPredicate("GEOSCoordSeq_hasM")
+
 cs_is_ccw = GEOSFuncFactory(
     "GEOSCoordSeq_isCCW", restype=c_int, argtypes=[CS_PTR, POINTER(c_byte)]
 )