Fixed #36712 -- Evaluated type annotations lazily in template tag registration.

#### Trac ticket number
ticket-36712

#### Branch description
Python 3.14 introduces [deferred evaluation of type annotations](https://docs.python.org/3/whatsnew/3.14.html#whatsnew314-deferred-annotations), which can make headaches around circular definitions go away.

`inspect.getfullargspec` wasn't updated to take the new `annotation_format` arg that was provided to let inspecting libraries (us) avoid eagerly evaluating user annotations, see https://github.com/[USER_REDACTED]n/cpython/issues/141560.

One solution would have been to copy and paste the entire function--along with importing the seven or eight other symbols it needed--and do the tweak, long-term forking the implementation.

Instead, patching the module helper anticipating the change we want Python to make felt more future-proof and sufficiently self-documenting. However, I'm happy to swap this out for a copy-and-paste approach if that's preferable, but we'd want to redistribute the python license in that case, I suppose?

Ideally, this will be reverted when an upstream solution is available for https://github.com/[USER_REDACTED]n/cpython/issues/141560.

[NAME_REDACTED] for the report and [NAME_REDACTED] for the first iteration and test.

#### 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.
- [x] I have attached screenshots in both light and dark modes for any UI changes.

@@ -1,6 +1,7 @@
 from inspect import getfullargspec

 from django.template.library import InclusionNode, parse_bits
+from django.utils.inspect import lazy_annotations

 class InclusionAdminNode(InclusionNode):
@@ -11,9 +12,10 @@ class InclusionAdminNode(InclusionNode):

     def __init__(self, parser, token, func, template_name, takes_context=True):
         self.template_name = template_name
-        params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = getfullargspec(
-            func
-        )
+        with lazy_annotations():
+            params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = (
+                getfullargspec(func)
+            )
         bits = token.split_contents()
         args, kwargs = parse_bits(
             parser,

@@ -60,6 +60,7 @@
 from django.utils.deprecation import django_file_prefixes
 from django.utils.formats import localize
 from django.utils.html import conditional_escape
+from django.utils.inspect import lazy_annotations
 from django.utils.regex_helper import _lazy_re_compile
 from django.utils.safestring import SafeData, SafeString, mark_safe
 from django.utils.text import get_text_list, smart_split, unescape_string_literal
@@ -825,7 +826,8 @@ def args_check(name, func, provided):
         # Check to see if a decorator is providing the real function.
         func = inspect.unwrap(func)

-        args, _, _, defaults, _, _, _ = inspect.getfullargspec(func)
+        with lazy_annotations():
+            args, _, _, defaults, _, _, _ = inspect.getfullargspec(func)
         alen = len(args)
         dlen = len(defaults or [])
         # Not enough OR Too many

@@ -4,6 +4,7 @@
 from inspect import getfullargspec, unwrap

 from django.utils.html import conditional_escape
+from django.utils.inspect import lazy_annotations

 from .base import Node, Template, token_kwargs
 from .exceptions import TemplateSyntaxError
@@ -110,15 +111,16 @@ def hello(*args, **kwargs):
         """

         def dec(func):
-            (
-                params,
-                varargs,
-                varkw,
-                defaults,
-                kwonly,
-                kwonly_defaults,
-                _,
-            ) = getfullargspec(unwrap(func))
+            with lazy_annotations():
+                (
+                    params,
+                    varargs,
+                    varkw,
+                    defaults,
+                    kwonly,
+                    kwonly_defaults,
+                    _,
+                ) = getfullargspec(unwrap(func))
             function_name = name or func.__name__

             [USER_REDACTED](func)
@@ -165,16 +167,16 @@ def hello(content):

         def dec(func):
             nonlocal end_name
-
-            (
-                params,
-                varargs,
-                varkw,
-                defaults,
-                kwonly,
-                kwonly_defaults,
-                _,
-            ) = getfullargspec(unwrap(func))
+            with lazy_annotations():
+                (
+                    params,
+                    varargs,
+                    varkw,
+                    defaults,
+                    kwonly,
+                    kwonly_defaults,
+                    _,
+                ) = getfullargspec(unwrap(func))
             function_name = name or func.__name__

             if end_name is None:
@@ -249,15 +251,16 @@ def show_results(poll):
         """

         def dec(func):
-            (
-                params,
-                varargs,
-                varkw,
-                defaults,
-                kwonly,
-                kwonly_defaults,
-                _,
-            ) = getfullargspec(unwrap(func))
+            with lazy_annotations():
+                (
+                    params,
+                    varargs,
+                    varkw,
+                    defaults,
+                    kwonly,
+                    kwonly_defaults,
+                    _,
+                ) = getfullargspec(unwrap(func))
             function_name = name or func.__name__

             [USER_REDACTED](func)

@@ -1,11 +1,19 @@
 import functools
 import inspect
+import threading
+from contextlib import contextmanager

 from django.utils.version import PY314

 if PY314:
     import annotationlib

+    lock = threading.Lock()
+    safe_signature_from_callable = functools.partial(
+        inspect._signature_from_callable,
+        annotation_format=annotationlib.Format.FORWARDREF,
+    )
+

 [USER_REDACTED].lru_cache(maxsize=512)
 def _get_func_parameters(func, remove_first):
@@ -98,3 +106,27 @@ def is_module_level_function(func):
         return False

     return True
+
+
+[USER_REDACTED]
+def lazy_annotations():
+    """
+    inspect.getfullargspec eagerly evaluates type annotations. To add
+    compatibility with Python 3.14+ deferred evaluation, patch the module-level
+    helper to provide the annotation_format that we are using elsewhere.
+
+    This private helper could be removed when there is an upstream solution for
+    https://github.com/[USER_REDACTED]n/cpython/issues/141560.
+
+    This context manager is not reentrant.
+    """
+    if not PY314:
+        yield
+        return
+    with lock:
+        original_helper = inspect._signature_from_callable
+        inspect._signature_from_callable = safe_signature_from_callable
+        try:
+            yield
+        finally:
+            inspect._signature_from_callable = original_helper

@@ -26,3 +26,7 @@ Bugfixes
   :class:`~django.http.HttpResponseRedirect` and
   :class:`~django.http.HttpResponsePermanentRedirect` for URLs longer than 2048
   characters. The limit is now 16384 characters (:ticket:`36743`).
+
+* Fixed a crash on Python 3.14+ that prevented template tag functions from
+  being registered when their type annotations required deferred evaluation
+  (:ticket:`36712`).

@@ -1,13 +1,17 @@
 import datetime
+import unittest

 from django.contrib.admin import ModelAdmin
 from django.contrib.admin.templatetags.admin_list import date_hierarchy
 from django.contrib.admin.templatetags.admin_modify import submit_row
+from django.contrib.admin.templatetags.base import InclusionAdminNode
 from django.contrib.auth import get_permission_codename
 from django.contrib.auth.admin import UserAdmin
 from django.contrib.auth.models import User
+from django.template.base import Token, TokenType
 from django.test import RequestFactory, TestCase
 from django.urls import reverse
+from django.utils.version import PY314

 from .admin import ArticleAdmin, site
 from .models import Article, Question
@@ -131,6 +135,22 @@ def test_override_change_list_template_tags(self):
         self.assertContains(response, "override-pagination")
         self.assertContains(response, "override-search_form")

+    [USER_REDACTED].skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+    def test_inclusion_admin_node_deferred_annotation(self):
+        def action(arg: SomeType = None):  # NOQA: F821
+            pass
+
+        # This used to raise TypeError via the underlying call to
+        # inspect.getfullargspec(), which is not ready for deferred
+        # evaluation of annotations.
+        InclusionAdminNode(
+            parser=object(),
+            token=Token(token_type=TokenType.TEXT, contents="a"),
+            func=action,
+            template_name="test.html",
+            takes_context=False,
+        )
+

 class DateHierarchyTests(TestCase):
     factory = RequestFactory()

@@ -1,8 +1,10 @@
 import functools
+import unittest

 from django.template import Library
 from django.template.base import Node
 from django.test import SimpleTestCase
+from django.utils.version import PY314

 class FilterRegistrationTests(SimpleTestCase):
@@ -78,6 +80,14 @@ def func():
         self.assertIs(func_wrapped, func)
         self.assertTrue(hasattr(func_wrapped, "cache_info"))

+    [USER_REDACTED].skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+    def test_inclusion_tag_deferred_annotation(self):
+        [USER_REDACTED].library.inclusion_tag("template.html")
+        def func(arg: SomeType):  # NOQA: F821
+            return ""
+
+        self.assertIn("func", self.library.tags)
+

 class SimpleTagRegistrationTests(SimpleTestCase):
     def setUp(self):
@@ -104,6 +114,14 @@ def func():

         self.assertIn("name", self.library.tags)

+    [USER_REDACTED].skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+    def test_tag_deferred_annotation(self):
+        [USER_REDACTED].library.simple_tag
+        def func(parser, token: SomeType):  # NOQA: F821
+            return Node()
+
+        self.assertIn("func", self.library.tags)
+
     def test_simple_tag_invalid(self):
         msg = "Invalid arguments provided to simple_tag"
         with self.assertRaisesMessage(ValueError, msg):
@@ -145,6 +163,14 @@ def func(content):

         self.assertIn("name", self.library.tags)

+    [USER_REDACTED].skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+    def test_simple_block_tag_deferred_annotation(self):
+        [USER_REDACTED].library.simple_block_tag
+        def func(content: SomeType):  # NOQA: F821
+            return content
+
+        self.assertIn("func", self.library.tags)
+
     def test_simple_block_tag_invalid(self):
         msg = "Invalid arguments provided to simple_block_tag"
         with self.assertRaisesMessage(ValueError, msg):

@@ -3,6 +3,8 @@
 These are *not* examples to be copied in user code.
 """

+import unittest
+
 from django.template import Library, TemplateSyntaxError
 from django.template.base import (
     FilterExpression,
@@ -15,6 +17,7 @@
 )
 from django.template.defaultfilters import register as filter_library
 from django.test import SimpleTestCase
+from django.utils.version import PY314

 class ParserTests(SimpleTestCase):
@@ -240,3 +243,17 @@ def test_filter_numeric_argument_parsing(self):
                     FilterExpression(num, p).resolve({})
                 with self.assertRaises(TemplateSyntaxError):
                     FilterExpression(f"0|default:{num}", p).resolve({})
+
+    [USER_REDACTED].skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+    def test_filter_deferred_annotation(self):
+        register = Library()
+
+        [USER_REDACTED].filter("example")
+        def example_filter(value: str, arg: SomeType):  # NOQA: F821
+            return f"{value}_{arg}"
+
+        result = FilterExpression.args_check(
+            "example", example_filter, ["extra_example"]
+        )
+
+        self.assertIs(result, True)

Looks good, added a few naming suggestions. Thank you! 🌟

LGTM, thanks!!!

Nit: For non-3.14 versions, this adds a lock around evaluting arguments. It's probably not huge, but in especially hot contexts it adds unnecessary extra work and could impact throughput. Perhaps worth a short-circuit for non-3.14 to avoid the lock?

```python
if not PY314:
    yield
else:
    with lock:
        ...
```

Should this `lock` also be inside the `if PY314:` guard?

I would suggest using the slightly shorter:

```suggestion
        helper_was_replaced = False
```

How about:

```suggestion
def lazy_deferred_annotations():
```

Is the bug in 5.2? or in 5.2.8? ideally we should be explicit about that. Also how about:

```suggestion
* Fixed a crashed in Django 5.2 and Python 3.14+ that prevented template tag
  functions from being registered when their type annotations required deferred
  evaluation (:ticket:`36712`).
```

Good call -- I had it like this, then changed it, because from the perspective of the finally block, it could be unclear what "replaced" means: replaced before the context was entered, or during the `try`?

But this isn't necessarily helping, so I'll revert.

Maybe more than a bug it's a missing piece of the support for Python 3.14. I'll update this like the note in 5.1.4.

No reason to have to `getattr` for this below, since we're relying on it here. This helper hasn't changed in python for a long time, it's likely pretty stable.


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/[USER_REDACTED]ngo/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.

@@ -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,16 +106,36 @@ 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)
+
     [USER_REDACTED]
     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))

     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 ###
     [USER_REDACTED]
     def size(self):
@@ -172,6 +224,18 @@ def hasz(self):
         """
         return self._z

+    [USER_REDACTED]
+    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+."
+            )
+
     # ### [NAME_REDACTED] ###
     def clone(self):
         "Clone this coordinate sequence."
@@ -180,16 +244,13 @@ def clone(self):
     [USER_REDACTED]
     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>"

     [USER_REDACTED]
     def tuple(self):

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

@@ -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)
+
+
 # ## [NAME_REDACTED] ctypes prototypes ##

 # [NAME_REDACTED] 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")

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

@@ -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]
+
+    [USER_REDACTED](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)
+
+    [USER_REDACTED](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)
+
+    [USER_REDACTED](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)
+
+    [USER_REDACTED](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>")
+
+    [USER_REDACTED](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)
+
+    [USER_REDACTED](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)
+
+    [USER_REDACTED](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

Awesome work, thanks a lot!

Question: shouldn't we also safeguard the `_get_m`/`set_m` method against usage with GEOS < 3.14 (like for `hasm`)?

Your comment on the PR could be also added somewhere to the PR content itself, otherwise I fear it may be forgotten and lost in the PR ocean.

Thank you!

I think, like the other recent PR, this should be strictly less than (3, 14) to account for patch updates.

... and then you might even catch that here with 3, 13, 13 or similar.

'requires'?

This is 0-indexed 🤯 !

```suggestion
    [USER_REDACTED](geos_version_tuple()[:2] < (3, 14), "GEOS M support requires 3.14+")
```

Does this work for you?

Elsewhere we just do:
```suggestion
    [USER_REDACTED](geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+")
```

(I don't mind either way, just noting what we already have.)


Extended scripts with more utilities.

Follow up of a523d5c8336f5f7f5e24a1cc8034ce65aedec3c6.

@@ -471,12 +471,25 @@ Building the artifacts
 .. admonition:: Optionally use helper scripts

     You can streamline some of the steps below using helper scripts from the
-    Wiki:
+    ``scripts`` folder:

-    * `Release script
-      <https://code.djangoproject.com/wiki/ReleaseScript>`_
-    * `Test new version script
-      <https://code.djangoproject.com/wiki/ReleaseTestNewVersion>`_
+    * Release script example run:
+
+    .. code-block:: shell
+
+        $ PGP_KEY_ID=<key-id> PGP_KEY_URL=<key-url> DEST_FOLDER=~/releases scripts/do_django_release.py
+
+    * Test new version script:
+
+    .. code-block:: shell
+
+        $ VERSION=5.2.1 scripts/test_new_version.sh
+
+    * Confirm release signature script:
+
+    .. code-block:: shell
+
+        $ VERSION=5.2.1 scripts/confirm_release.sh

 #. Tag the release using ``git tag``. For example:

@@ -692,6 +705,15 @@ You're almost done! All that's left to do now is:
    #. Update djangoproject.com's download page (`example PR
       <https://github.com/[USER_REDACTED]ngo/djangoproject.com/pull/1444>`__).

+   #. Process the older versions that will reach End-Of-Mainstream and/or
+      End-Of-Life support when this final release is published:
+
+      #. Ensure that the EOL versions are mentioned in the blog post. `Example
+         <https://www.djangoproject.com/weblog/2025/apr/02/django-52-released/>`_.
+
+      #. Create a tag for the EOL stable branch and delete the stable branch.
+         Inspect and use the ``scripts/archive_eol_stable_branches.py`` helper.
+
 #. If this was a security release, update :doc:`/releases/security` with
    details of the issues addressed.

@@ -0,0 +1,151 @@
+#! /usr/bin/env python3
+import argparse
+import os
+import subprocess
+import sys
+
+
+def run(cmd, *, cwd=None, env=None, dry_run=True):
+    """Run a command with optional dry-run behavior."""
+    environ = os.environ.copy()
+    if env:
+        environ.update(env)
+    if dry_run:
+        print("[DRY RUN]", " ".join(cmd))
+    else:
+        print("[EXECUTE]", " ".join(cmd))
+        try:
+            result = subprocess.check_output(
+                cmd, cwd=cwd, env=environ, stderr=subprocess.STDOUT
+            )
+        except subprocess.CalledProcessError as e:
+            result = e.output
+            print("    [ERROR]", result)
+            raise
+        else:
+            print("    [RESULT]", result)
+        return result.decode().strip()
+
+
+def validate_env(checkout_dir):
+    if not checkout_dir:
+        sys.exit("Error: checkout directory not provided (--checkout-dir).")
+    if not os.path.exists(checkout_dir):
+        sys.exit(f"Error: checkout directory '{checkout_dir}' does not exist.")
+    if not os.path.isdir(checkout_dir):
+        sys.exit(f"Error: '{checkout_dir}' is not a directory.")
+
+
+def get_remote_branches(checkout_dir, include_fn):
+    """Return list of remote branches filtered by include_fn."""
+    result = run(
+        ["git", "branch", "--list", "-r"],
+        cwd=checkout_dir,
+        dry_run=False,
+    )
+    branches = [b.strip() for b in result.split("\n") if b.strip()]
+    return [b for b in branches if include_fn(b)]
+
+
+def get_branch_info(checkout_dir, branch):
+    """Return (commit_hash, last_update_date) for a given branch."""
+    commit_hash = run(["git", "rev-parse", branch], cwd=checkout_dir, dry_run=False)
+    last_update = run(
+        ["git", "show", branch, "--format=format:%ai", "-s"],
+        cwd=checkout_dir,
+        dry_run=False,
+    )
+    return commit_hash, last_update
+
+
+def create_tag(checkout_dir, branch, commit_hash, last_update, *, dry_run=True):
+    """Create a tag locally for a given branch at its last update."""
+    tag_name = branch.replace("origin/", "", 1)
+    msg = f'"Tagged {tag_name} for EOL stable branch removal."'
+    run(
+        ["git", "tag", "--sign", "--message", msg, tag_name, commit_hash],
+        cwd=checkout_dir,
+        env={"GIT_COMMITTER_DATE": last_update},
+        dry_run=dry_run,
+    )
+    return tag_name
+
+
+def delete_remote_and_local_branch(checkout_dir, branch, *, dry_run=True):
+    """Delete a remote branch from origin and the maching local branch."""
+    try:
+        run(
+            ["git", "branch", "-D", branch],
+            cwd=checkout_dir,
+            dry_run=dry_run,
+        )
+    except subprocess.CalledProcessError:
+        print(f"[ERROR] Local branch {branch} can not be deleted.")
+
+    run(
+        ["git", "push", "origin", "--delete", branch.replace("origin/", "", 1)],
+        cwd=checkout_dir,
+        dry_run=dry_run,
+    )
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="[NAME_REDACTED] branches into tags and optionally delete them."
+    )
+    parser.add_argument(
+        "--checkout-dir", required=True, help="Path to Django git checkout"
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Print commands instead of executing them",
+    )
+    parser.add_argument(
+        "--branches", nargs="*", help="Specific remote branches to include (optional)"
+    )
+    args = parser.parse_args()
+
+    validate_env(args.checkout_dir)
+    dry_run = args.dry_run
+    checkout_dir = args.checkout_dir
+
+    if args.branches:
+        wanted = set(f"origin/{b}" for b in args.branches)
+    else:
+        wanted = set()
+
+    branches = get_remote_branches(checkout_dir, include_fn=lambda b: b in wanted)
+    if not branches:
+        print("No branches matched inclusion criteria.")
+        return
+
+    print("\nMatched branches:")
+    print("\n".join(branches))
+    print()
+
+    branch_updates = {b: get_branch_info(checkout_dir, b) for b in branches}
+    print("\nLast updates:")
+    for b, (h, d) in branch_updates.items():
+        print(f"{b}\t{h}\t{d}")
+
+    if (
+        input("\nDelete remote branches and create tags? [y/N]: ").strip().lower()
+        == "y"
+    ):
+        for b, (commit_hash, last_update_date) in branch_updates.items():
+            print(f"Creating tag for {b} at {commit_hash=} with {last_update_date=}")
+            create_tag(checkout_dir, b, commit_hash, last_update_date, dry_run=dry_run)
+            print(f"Deleting remote branch {b}")
+            delete_remote_and_local_branch(checkout_dir, b, dry_run=dry_run)
+        run(
+            ["git", "push", "--tags"],
+            cwd=checkout_dir,
+            dry_run=dry_run,
+        )
+
+    print("Done.")
+
+
+if __name__ == "__main__":
+    main()

@@ -25,7 +25,7 @@ echo "Download checksum file ..."
 curl --fail --output "$CHECKSUM_FILE" "${MEDIA_URL_PREFIX}/pgp/${CHECKSUM_FILE}"

 echo "Verify checksum file ..."
-if [ -n "${GPG_KEY}" ] ; then
+if [ -n "${GPG_KEY:-}" ] ; then
     gpg --recv-keys "${GPG_KEY}"
 fi
 gpg --verify "${CHECKSUM_FILE}"

Looks good 😎

I'm not familiar with this syntax, can you throw me a hint?

Is this "EOL or end-of-mainstream support versions"? Otherwise I wasn't sure why end-of-mainstream... was referenced.

The example blogpost shows how, on some final releases, we announce the end of extended support for a version (full EOL) and the end of mainstream support for other (security support still available). Does that help in any way?

Great, that's what I thought. Then I would suggest:

"Ensure that the EOL versions" -> "Ensure that the EOL or End-of-Mainstream support versions"

Sure! This bash syntax is used to set a default if the `GPG_KEY` var is not defined. Without it, I was getting:
```
scripts/confirm_release.sh: line 28: GPG_KEY: unbound variable
```
since we are running the scripts in a pedantic-ish manner where `set -u` complains about undefined variables.


Closed pool when parallel test runner encounters unpicklable exceptions.

**Before**
```py
multiprocessing.pool.MaybeEncodingError: Error sending result: '<multiprocessing.pool.ExceptionWithTraceback object at 0x10bbecec0>'. Reason: 'TypeError("cannot pickle 'BufferedReader' instances")'
Exception ignored while calling deallocator <function Pool.__del__ at 0x107aaa610>:
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/pool.py", line 268, in __del__
ResourceWarning: unclosed running multiprocessing pool <multiprocessing.pool.Pool state=RUN pool_size=8>
```
**After**
```py
multiprocessing.pool.MaybeEncodingError: Error sending result: '<multiprocessing.pool.ExceptionWithTraceback object at 0x109c18d70>'. Reason: 'TypeError("cannot pickle 'BufferedReader' instances")'
```

To reproduce, cause a failure in `tests/servers` by [NAME_REDACTED]:
```diff
diff --git a/django/test/testcases.py b/django/test/testcases.py
index c587f770a6..40e785f5dc 100644
--- a/django/test/testcases.py
+++ b/django/test/testcases.py
@@ -1824,11 +1824,11 @@ class LiveServerTestCase(TransactionTestCase):

     [USER_REDACTED]
     def setUpClass(cls):
-        super().setUpClass()
         cls.enterClassContext(
             modify_settings(ALLOWED_HOSTS={"append": cls.allowed_host})
         )
         cls._start_server_thread()
+        super().setUpClass()

     [USER_REDACTED]
     def _start_server_thread(cls):
```

@@ -567,7 +567,14 @@ def run(self, result):
         """
         self.initialize_suite()
         counter = multiprocessing.Value(ctypes.c_int, 0)
-        pool = multiprocessing.Pool(
+        args = [
+            (self.runner_class, index, subsuite, self.failfast, self.buffer)
+            for index, subsuite in enumerate(self.subsuites)
+        ]
+        # Don't buffer in the main process to avoid error propagation issues.
+        result.buffer = False
+
+        with multiprocessing.Pool(
             processes=self.processes,
             initializer=functools.partial(_safe_init_worker, self.init_worker.__func__),
             initargs=[
@@ -579,38 +586,30 @@ def run(self, result):
                 self.debug_mode,
                 self.used_aliases,
             ],
-        )
-        args = [
-            (self.runner_class, index, subsuite, self.failfast, self.buffer)
-            for index, subsuite in enumerate(self.subsuites)
-        ]
-        # Don't buffer in the main process to avoid error propagation issues.
-        result.buffer = False
-
-        test_results = pool.imap_unordered(self.run_subsuite.__func__, args)
-
-        while True:
-            if result.shouldStop:
-                pool.terminate()
-                break
-
-            try:
-                subsuite_index, events = test_results.next(timeout=0.1)
-            except multiprocessing.TimeoutError as err:
-                if counter.value < 0:
-                    err.add_note("ERROR: _init_worker failed, see prior traceback")
+        ) as pool:
+            test_results = pool.imap_unordered(self.run_subsuite.__func__, args)
+
+            while True:
+                if result.shouldStop:
+                    pool.terminate()
+                    break
+
+                try:
+                    subsuite_index, events = test_results.next(timeout=0.1)
+                except multiprocessing.TimeoutError as err:
+                    if counter.value < 0:
+                        err.add_note("ERROR: _init_worker failed, see prior traceback")
+                        raise
+                    continue
+                except StopIteration:
                     pool.close()
-                    raise
-                continue
-            except StopIteration:
-                pool.close()
-                break
+                    break

-            tests = list(self.subsuites[subsuite_index])
-            for event in events:
-                self.handle_event(result, tests, event)
+                tests = list(self.subsuites[subsuite_index])
+                for event in events:
+                    self.handle_event(result, tests, event)

-        pool.join()
+            pool.join()

         return result

@@ -309,9 +309,8 @@ def fake_next(*args, **kwargs):
                 test_result.shouldStop = True
                 return (0, remote_result.events)

-            mock_pool.return_value.imap_unordered.return_value = unittest.mock.Mock(
-                next=fake_next
-            )
+            mock_imap = mock_pool.return_value.__enter__.return_value.imap_unordered
+            mock_imap.return_value = unittest.mock.Mock(next=fake_next)
             pts.run(test_result)

         self.assertIn("ValueError: woops", test_result.errors[0][1])

Do you know if there is a reason why we don't use `pool` as a context manager?

It looks likes something `__exit__` should handle by itself?

I think we don't need the `pool.close()` on the line below this now.

I get the following when I try that (given that we only `break` and not `raise`):

```py
  File "/Users/jwalls/django/django/test/runner.py", line 611, in run
    pool.join()
    ~~~~~~~~~^^
  File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/pool.py", line 662, in join
    raise ValueError("Pool is still running")
ValueError: Pool is still running
```


Fixed #29257 -- [NAME_REDACTED] when attempting to close a possibly already-closed cursor.

#### Trac ticket number

ticket-29257

#### Branch description
When ``cursor.execute()`` raises an exception and ``cursor.close()`` also raises, Django used to surface the ``cursor.close()`` error, adding noise to the original execution failure and producing misleading messages such as: "cursor _django_curs_xxx does not exist".

This patch ensures that the original ``execute()`` exception is preserved without unnecessary context. A regression test is included to verify that ``cursor.close()`` errors do not distract from the real underlying exception.

#### 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.

@@ -1629,9 +1629,12 @@ def execute_sql(
             cursor = self.connection.cursor()
         try:
             cursor.execute(sql, params)
-        except Exception:
+        except Exception as e:
             # Might fail for server-side cursors (e.g. connection closed)
-            cursor.close()
+            try:
+                cursor.close()
+            except DatabaseError:
+                raise e from None
             raise

         if result_type == ROW_COUNT:

@@ -1,11 +1,14 @@
-from django.db import DEFAULT_DB_ALIAS, connection
+from unittest import mock
+
+from django.db import DEFAULT_DB_ALIAS, DatabaseError, connection
 from django.db.models.sql import Query
-from django.test import SimpleTestCase
+from django.db.models.sql.compiler import SQLCompiler
+from django.test import TestCase

 from .models import Item

-class SQLCompilerTest(SimpleTestCase):
+class SQLCompilerTest(TestCase):
     def test_repr(self):
         query = Query(Item)
         compiler = query.get_compiler(DEFAULT_DB_ALIAS, connection)
@@ -15,3 +18,24 @@ def test_repr(self):
             f"<DatabaseWrapper vendor={connection.vendor!r} alias='default'> "
             f"using='default'>",
         )
+
+    def test_execute_sql_suppresses_cursor_closing_failure_on_exception(self):
+        query = Query(Item)
+        compiler = SQLCompiler(query, connection, None)
+        cursor = mock.MagicMock()
+
+        # When execution fails, the cursor may have been closed.
+        # Django's attempt to close it again will fail, and needs catching.
+        execute_err = DatabaseError("execute failed")
+        cursor.execute.side_effect = execute_err
+        cursor.close.side_effect = DatabaseError("close failed")
+
+        with mock.patch.object(connection, "cursor", return_value=cursor):
+            with self.assertRaises(DatabaseError) as ctx:
+                compiler.execute_sql("SELECT 1", [])
+
+        # There is no irrelevant context from trying to close a closed cursor.
+        exc = ctx.exception
+        self.assertIs(exc, execute_err)
+        self.assertIsNone(exc.__cause__)
+        self.assertTrue(exc.__suppress_context__)

Good stuff [USER_REDACTED], with some polish this should be good to go!

Thanks [USER_REDACTED] 👍.

I moved the test to `SQLCompilerTest` and adjusted it slightly. Looks good!

```suggestion
            except DatabaseError:
```

Please remove the comments.

Please move this comment into the new inner except.

Assert against `ctx.exception.__cause__` and `__suppress_context__` similar to `test_fetch_mode_raise_forward` and others.

You can remove most of these comments. You can leave the execution fails & closing fails comments if you like, as that's the interesting part -- the fact that we need nested failures.

The original exception was already raised, it just had extra context, so I'd suggest tweaking this title.

You can remove the model:

```diff
diff --git a/tests/queries/test_execute_sql.py b/tests/queries/test_execute_sql.py
index 6e9e578093..213ac21f53 100644
--- a/tests/queries/test_execute_sql.py
+++ b/tests/queries/test_execute_sql.py
@@ -1,21 +1,15 @@
 from unittest import mock

-from django.db import DatabaseError, connection, models
+from django.db import DatabaseError, connection
 from django.db.models.sql.compiler import SQLCompiler
 from django.db.models.sql.query import Query
 from django.test import TestCase

-class DummyModel(models.Model):
-    class Meta:
-        app_label = "tests"
-        managed = False
-
-
 class ExecuteSqlCursorCloseErrorTests(TestCase):
     def test_execute_sql_raises_original_exception_when_close_fails(self):
         # Build compiler
-        query = Query(DummyModel)
+        query = Query(None)
         compiler = SQLCompiler(query, connection, None)

         # Fake cursor
```

I'll adjust this for you.

Django uses unittest-style asserts.


Fixed #36112 -- Added fallback in last_executed_query() on Oracle and PostgreSQL.

#### Trac ticket number
ticket-36112

#### Branch description
This PR fixes the issue described in ticket #36112, where ``DatabaseOperations.last_executed_query()`` in the Oracle backend may return ``None`` when a query fails. This causes SQL formatting to crash under ``--debug-sql``.

Following the core team’s guidance on the ticket ([NAME_REDACTED] & [NAME_REDACTED]), backend implementations should fall back to ``super().last_executed_query()`` rather than returning ``None``.

This patch:
- Adds the fallback for Oracle.
- Matches the expected backend contract.
- Has no behavioral impact on successful queries.
- Only affects debug/inspection paths.
- Requires no new tests (ticket metadata: Needs tests: no).

This completes the cleanup after #36380.

#### 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.

@@ -352,7 +352,11 @@ def last_executed_query(self, cursor, sql, params):
                 statement = statement.replace(
                     key, force_str(params[key], errors="replace")
                 )
-        return statement
+        return (
+            super().last_executed_query(cursor, sql, params)
+            if statement is None
+            else statement
+        )

     def last_insert_id(self, cursor, table_name, pk_name):
         sq_name = self._get_sequence_name(cursor, strip_quotes(table_name), pk_name)

@@ -302,11 +302,11 @@ def last_executed_query(self, cursor, sql, params):
                 try:
                     return self.compose_sql(sql, params)
                 except errors.DataError:
-                    return None
+                    return super().last_executed_query(cursor, sql, params)
             else:
                 if cursor._query and cursor._query.query is not None:
                     return cursor._query.query.decode()
-                return None
+                return super().last_executed_query(cursor, sql, params)

     else:

@@ -315,7 +315,7 @@ def last_executed_query(self, cursor, sql, params):
             # The query attribute is a Psycopg extension to the DB API 2.0.
             if cursor.query is not None:
                 return cursor.query.decode()
-            return None
+            return super().last_executed_query(cursor, sql, params)

     if is_psycopg3:

@@ -171,6 +171,19 @@ class DatabaseOperationTests(TestCase):
     def setUp(self):
         self.ops = BaseDatabaseOperations(connection=connection)

+    def test_last_executed_query_base_fallback(self):
+        sql = "INVALID SQL"
+        params = []
+        with connection.cursor() as cursor:
+            cursor.close()
+            try:
+                cursor.execute(sql, params)
+            except connection.features.closed_cursor_error_class:
+                pass
+            self.assertIsNotNone(
+                connection.ops.last_executed_query(cursor, sql, params),
+            )
+
     [USER_REDACTED]("can_distinct_on_fields")
     def test_distinct_on_fields(self):
         msg = "DISTINCT ON fields is not supported by [NAME_REDACTED]"

This is not a regression test. TBH it is not a test at all.

Why do you need a new instance of `DatabaseOperations`? Also it should be enough to check that result of `last_executed_query()` is not `None`.
```suggestion

[USER_REDACTED](connection.vendor == "oracle", "Oracle specific tests")
class TestLastExecutedQueryFallback(TestCase):
    def test_last_executed_query_fallback(self):
        class FakeCursor:
            # Simulates a cursor lacking the `statement` attribute
            pass

        cursor = FakeCursor()
        sql = "SELECT 1"
        params = []

        self.assertIsNotNone(connection.ops.last_executed_query(cursor, sql, params))
```

We could also use a real cursor with an invalid SQL statement, e.g.
```
with connection.cursor() as cursor:
    sql = "INVALID SQL"
    params = []
    cursor.execute(sql, params)
    self.assertEqual(connection.ops.last_executed_query(cursor, sql, params), sql)
```

As far as I'm aware it's unnecessary.

`assertIsNotNone` is redundant
```suggestion
            self.assertEqual(result, sql)
```

As far as I'm aware, this test should pass for all database backends, so we can move it to the
`tests.backends.base.test_operations.DatabaseOperationTests` class.

I think we should use `super().last_executed_query()` only when `statement` is None
```suggestion
        return super().last_executed_query(cursor, sql, params) if statement is None else statement
```

This test pass even without a fix, you need to `close()` the cursor to have a proper regression test.
```suggestion
                cursor.close()
                cursor.execute(sql, params)
```


Fixed #36030 -- Fixed precision loss in division of Decimal literals on SQLite.

#### Trac ticket number
ticket-36030

#### Branch description

On SQLite, ``SQLiteNumericMixin.as_sqlite()`` currently wraps all ``DecimalField`` expressions in ``CAST(... AS NUMERIC)``, including literal ``Value(Decimal(...))`` expressions.
For these literals, the cast is unnecessary and differs from how other backends pass Decimal parameters.

The solution decouples ``Value`` from ``SQLiteNumericMixin`` by implementing a dedicated ``as_sqlite`` method that explicitly casts ``Decimal`` literals to ``REAL``, ensuring correct floating-point arithmetic is used. To prevent regressions, the implementation retains the existing ``CAST(... AS NUMERIC)`` wrapper for ``non-Decimal`` types (such as integers) when they are output to a DecimalField.

**Tests**
A new regression test, ``test_decimal_division_literal_value``, has been added to ``BasicExpressionsTests`` to verify that division yields the correct fractional precision on all backends.

#### 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.
- [x] I have checked the "Has patch" flag on the Trac ticket.
- [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 light/dark modes for UI changes (N/A).

@@ -1141,7 +1141,7 @@ def allowed_default(self):

 [USER_REDACTED](path="django.db.models.Value")
-class Value(SQLiteNumericMixin, Expression):
+class Value(Expression):
     """Represent a wrapped value as a node within an expression."""

     # Provide a default value for `for_save` in order to allow unresolved
@@ -1182,6 +1182,18 @@ def as_sql(self, compiler, connection):
             return "NULL", []
         return "%s", [val]

+    def as_sqlite(self, compiler, connection, **extra_context):
+        sql, params = self.as_sql(compiler, connection, **extra_context)
+        try:
+            if self.output_field.get_internal_type() == "DecimalField":
+                if isinstance(self.value, Decimal):
+                    sql = "(CAST(%s AS REAL))" % sql
+                else:
+                    sql = "(CAST(%s AS NUMERIC))" % sql
+        except FieldError:
+            pass
+        return sql, params
+
     def resolve_expression(
         self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False
     ):

@@ -137,6 +137,16 @@ def test_annotate_values_aggregate(self):
         )
         self.assertEqual(companies["result"], 2395)

+    def test_decimal_division_literal_value(self):
+        """
+        Division with a literal Decimal value preserves precision.
+        """
+        num = Number.objects.create(integer=2)
+        obj = Number.objects.annotate(
+            val=F("integer") / Value(Decimal("3.0"), output_field=DecimalField())
+        ).get(pk=num.pk)
+        self.assertAlmostEqual(obj.val, Decimal("0.6667"), places=4)
+
     def test_annotate_values_filter(self):
         companies = (
             Company.objects.annotate(

This appears to fix the problem as far as I can tell. Your test passes with the fix and fails without it (which is what we want). Same is true for the repro case at the top of the ticket. I personally would have been inclined to write the test so that it's verifying the correctness of the observable outcome (along the lines of that repro case), rather than the mechanics of the solution. But I'll leave it to those higher up the food chain than I am to decide whether that should prevent the PR from being accepted and applied.

Thanks, nice start [USER_REDACTED] 👍

Instead of adding an if/else switch here, did you consider just implementing `Value.as_sqlite()` and removing this mixin from that class?

We avoid preambles like "verify" in test docstrings.

"behavior" isn't specific.

As [USER_REDACTED] mentioned, we usually avoid asserting over the underlying SQL. The other test should be sufficient.

I think we want this test to run on all backends. So instead of placing it in a `test_sqlite` file, can you place it right after the test that was added for this casting behavior in the first place, i.e. right after `test_filter_decimal_annotation()`?

If we're only comparing four places, I'd adjust the second arg to use four places.

I think you can chop this comment.

Hi, [USER_REDACTED]. The original ticket was not specific to SQLite (the original reporter was using PostgreSQL), but the ticket was repurposed (based on an interpretation of what [USER_REDACTED] intended in the comment she posted December 20, 2024 when the ticket was accepted) narrowing it to the SQLite back end. Should we restore the broader focus of the ticket? I don't believe the new test will pass with PostgreSQL unless the PR is expanded (in the direction of the previous PR which was rejected).

This test as written passes on postgres (and currently runs on postgres; there is no skip decorator on it).

You're absolutely right. I stand corrected. 😊

no worries, appreciate having your eyes on it!

Thanks, I think this is a good idea for now given that we are checking `self.value` below. We may find similar edge cases with the other expressions that would involve different kinds of introspection `self.cases`, `self.source_expressions`, etc., but we can cross that bridge later.


Fixed #36751 -- Fixed empty filtered aggregation crash over annotated queryset.

#### Trac ticket number
ticket-36404

#### Branch description
Regression in b8e5a8a9a2a767f584cbe89a878a42363706f939.

Refs ticket-36404.

The `replace_expressions` method was innapropriately dealing with falsey but not `None` source expressions causing them to also be potentially evaluated when `__bool__` was invoked (e.g. `QuerySet.__bool__` evalutates the query).

The changes introduced in b8e5a8a9a2, which were to deal with a similar issue, surfaced the problem as aggregation over an annotated queryset requires an inlining (or pushdown) of aggregate references which is achieved through `replace_expressions`.

In cases where an empty `Q` objects was provided as an aggregate filter, such as when the admin facetting feature was used as reported, it would wrongly be turned into `None`, instead of an empty `WhereNode`, causing a crash at aggregate filter compilation.

Note that the crash signature differed depending on whether or not the backend natively supports aggregate filtering (`supports_aggregate_filter_clause`) as the fallback, which makes use `Case` / `When` expressions, would result in a `TypeError` instead of a `NoneType` `AttributeError`.

Thanks [USER_REDACTED] for the report, Antoliny and [USER_REDACTED] for the triage.

@@ -426,7 +426,7 @@ def replace_expressions(self, replacements):
         clone = self.copy()
         clone.set_source_expressions(
             [
-                expr.replace_expressions(replacements) if expr else None
+                None if expr is None else expr.replace_expressions(replacements)
                 for expr in source_expressions
             ]
         )

@@ -16,3 +16,7 @@ Bugfixes

 * Fixed a bug in Django 5.2 on PostgreSQL where ``bulk_create()`` did not apply
   a field's custom query placeholders (:ticket:`36748`).
+
+* Fixed a regression in Django 5.2.2 that caused a crash when using aggregate
+  functions with an empty ``Q`` filter over a queryset with annotations
+  (:ticket:`36751`).

@@ -88,6 +88,17 @@ def test_empty_filtered_aggregates(self):
         agg = Count("pk", filter=Q())
         self.assertEqual(Author.objects.aggregate(count=agg)["count"], 3)

+    def test_empty_filtered_aggregates_with_annotation(self):
+        agg = Count("pk", filter=Q())
+        self.assertEqual(
+            Author.objects.annotate(
+                age_annotation=F("age"),
+            ).aggregate(
+                count=agg
+            )["count"],
+            3,
+        )
+
     def test_double_filtered_aggregates(self):
         agg = Sum("age", filter=Q(Q(name="test2") & ~Q(name="test")))
         self.assertEqual(Author.objects.aggregate(age=agg)["age"], 60)

@@ -1571,6 +1571,24 @@ def test_get_expression_for_validation_only_one_source_expression(self):
         with self.assertRaisesMessage(ValueError, msg):
             expression.get_expression_for_validation()

+    def test_replace_expressions_falsey(self):
+        class AssignableExpression(Expression):
+            def __init__(self, *source_expressions):
+                super().__init__()
+                self.set_source_expressions(list(source_expressions))
+
+            def get_source_expressions(self):
+                return self.source_expressions
+
+            def set_source_expressions(self, exprs):
+                self.source_expressions = exprs
+
+        expression = AssignableExpression()
+        falsey = Q()
+        expression.set_source_expressions([falsey])
+        replaced = expression.replace_expressions({"replacement": Expression()})
+        self.assertEqual(replaced.get_source_expressions(), [falsey])
+

 class ExpressionsNumericTests(TestCase):
     [USER_REDACTED]

I can confirm that this PR resolves the issue observed in ticket-36751 👍

I wanted to use `Expression` directly but it doesn't allow assignment of `source_expressions`

https://github.com/[USER_REDACTED]ngo/django/blob/ec60df6d1ea8939a316d9b180faa0b4ef2e83606/django/db/models/expressions.py#L212-L213

We could use any object with a `__bool__() -> False` but I figured it was simpler to use `Q` directly since it's the case that triggered the reported problem.

As mentionned in the commit message this would have caused objects such as `QuerySet` to be evaluated as replacement as well (since `QuerySet.__bool__` executes the query).


Fixed #36620 -- Fixed workflow to summarize coverage in PRs.

ref - https://github.com/[USER_REDACTED]ngo/django/pull/19777#issuecomment-3567658385

The coverage workflow was reporting "No lines with coverage information in this diff" even when PRs contained changes to Python files that were executed during tests.

since we switched to `pull_request_target`, which by [NAME_REDACTED] **base branch** (main) instead of the PR branch.

### [NAME_REDACTED] explicit checkout of the PR branch using `ref: ${{ github.event.pull_request.head.sha }}`:

```yaml
- name: Checkout
  uses: actions/checkout@v5
  with:
    ref: ${{ github.event.pull_request.head.sha }}  # ← Checkout PR code
    fetch-depth: 0
    persist-credentials: false
```

@@ -0,0 +1,72 @@
+name: [NAME_REDACTED]
+
+on:
+  workflow_run:
+    workflows: ["[NAME_REDACTED]"]
+    types:
+      - completed
+
+permissions:
+  contents: read
+  pull-requests: write
+
+jobs:
+  comment:
+    if: >
+      github.event.workflow_run.event == 'pull_request' &&
+      github.event.workflow_run.conclusion == 'success' &&
+      github.repository == 'django/django' &&
+      github.event.workflow_run.pull_requests[0] != null
+    name: [NAME_REDACTED]
+    runs-on: ubuntu-latest
+    timeout-minutes: 60
+    steps:
+      - name: Download diff coverage report
+        uses: actions/download-artifact@v4
+        with:
+          name: diff-coverage-report-${{ github.event.workflow_run.pull_requests[0].number }}
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          run-id: ${{ github.event.workflow_run.id }}
+
+      - name: Post/update PR comment
+        env:
+          PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
+        uses: actions/github-script@v8
+        with:
+          script: |
+            const fs = require('fs');
+            const reportPath = 'diff-cover-report.md';
+            let body = 'No coverage data available.';
+            if (fs.existsSync(reportPath)) {
+              body = fs.readFileSync(reportPath, 'utf8');
+            }
+            const commentBody = '### 📊 [NAME_REDACTED] for Changed Files\n\n```\n' + body + '\n```\n\n**Note:** Missing lines are warnings only. Some lines may not be covered by [NAME_REDACTED] are database-specific.\n\nFor more information about code coverage on pull requests, see the [contributing documentation](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/unit-tests/#code-coverage-on-pull-requests).';
+
+            const prNumber = parseInt(process.env.PR_NUMBER);
+            if (isNaN(prNumber)) {
+              core.setFailed('PR number is not available or invalid.');
+              return;
+            }
+
+            const { data: comments } = await github.rest.issues.listComments({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: prNumber,
+            });
+
+            for (const c of comments) {
+              if ((c.body || '').includes('📊 [NAME_REDACTED] for Changed Files')) {
+                await github.rest.issues.deleteComment({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  comment_id: c.id,
+                });
+              }
+            }
+
+            await github.rest.issues.createComment({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: prNumber,
+              body: commentBody,
+            });

@@ -0,0 +1,75 @@
+name: [NAME_REDACTED]
+
+on:
+  pull_request:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+
+jobs:
+  coverage:
+    if: github.repository == 'django/django'
+    name: [NAME_REDACTED] (Windows)
+    runs-on: windows-latest
+    timeout-minutes: 60
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v5
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: '3.14'
+          cache: 'pip'
+          cache-dependency-path: 'tests/requirements/py3.txt'
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip wheel
+          python -m pip install -r tests/requirements/py3.txt -e .
+          python -m pip install 'coverage[toml]' diff-cover
+
+      - name: Run tests with coverage
+        env:
+          PYTHONPATH: ${{ github.workspace }}/tests
+          COVERAGE_PROCESS_START: ${{ github.workspace }}/tests/.coveragerc
+          RUNTESTS_DIR: ${{ github.workspace }}/tests
+        run: |
+          python -Wall tests/runtests.py -v2
+
+      - name: Generate coverage report
+        if: success()
+        env:
+          COVERAGE_RCFILE: ${{ github.workspace }}/tests/.coveragerc
+          RUNTESTS_DIR: ${{ github.workspace }}/tests
+        run: |
+          python -m coverage combine
+          python -m coverage report --show-missing
+          python -m coverage xml -o tests/coverage.xml
+
+      - name: Generate diff coverage report
+        if: success()
+        run: |
+          if (Test-Path 'tests/coverage.xml') {
+            diff-cover tests/coverage.xml --compare-branch=origin/main --fail-under=0 > diff-cover-report.md
+          } else {
+            Set-Content -Path diff-cover-report.md -Value 'No coverage data available.'
+          }
+
+      - name: Upload diff coverage report
+        if: success()
+        uses: actions/upload-artifact@v4
+        with:
+          name: diff-coverage-report-${{ github.event.pull_request.number }}
+          path: diff-cover-report.md
+          retention-days: 1

@@ -19,6 +19,7 @@ permissions:

 jobs:
   windows:
+    if: github.event_name == 'push'
     runs-on: windows-latest
     strategy:
       matrix:

@@ -1,6 +1,7 @@
 rules:
   dangerous-triggers:
     ignore:
+      - coverage_comment.yml
       - labels.yml
       - new_contributor_pr.yml
   unpinned-uses:

Thanks for the quick response 👍

Thanks for the updates 👍

Looking great, after this round I'll test on my fork.

Great, this is working well!

Thanks [USER_REDACTED], really appreciate the extra care here.

Can we use this as an opportunity to revisit the need for `pull_request_target`? I see the earlier draft of the feature PR on your fork used `pull_request` and `permissions: pull-requests: write` -- can you catch me up on why that was removed?

please revert.

cause `pull_request` doesn't have write permissions for PRs from forks.
see - [ref](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target)

added this as sarah suggested [here](https://github.com/0saurabh0/django/pull/6#issuecomment-3219300658)
Should I remove?

We should have the newline like Sarah mentioned, but as I see in the diff the PR removes the newline.

ah, my bad.
will do.

Right, okay. Based on my level of understanding right now, I'm not comfortable checking out user-controlled code under a `pull_request_target` action.

The advice here (granted it's 4 years old) is to have a two-workflow solution that passes the diff report as an artifact to the workflow with the write permission:
https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/

> Right, okay. Based on my level of understanding right now, I'm not comfortable checking out user-controlled code under a `pull_request_target` action.

we can't run user code, but can use the code for git diff (passive data reading)? right?

We can check out user code under the `pull_request` trigger, not the `pull_request_target` one, because it limits secrets access and write permissions. The existing `python_matrix.yml` and `tests.yml` do this. So the idea would be to run the user code under that safer sandbox, generate the artifact, and then pass it along to the workflow that will write the comment (under the `pull_request_target` trigger).

My idea behind splitting the workflows was to avoid having to checkout out an untrusted ref under `pull_request_target` or `pull-requests: write`, so are we able to just skip this? (If not, I imagine we could move generating the diff-cover artifact into the other workflow.)
```suggestion
```

Need a newline instead of indentation, same in the other file.

The other workflow files use underscores.

yes, updated

Now that we have two workflows, can we use the `workflow_call` [trigger](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows) to avoid this ignore?

With `workflow_call`, wouldn't the called workflow would inherit the limited permissions from the fork PR context and couldn't post comments?

Ah got it, thanks. Testing on my fork now.

This would be nice to avoid windows shell commands

```suggestion
    runs-on: ubuntu-latest
```

If we're extracting the pr number here, do we need to have a pr_number.txt artifact?

Pardon me if asked before, but does this need a `push` trigger?

We recently added timeout-minutes: 60 to all jobs, so please add that here also.

I didn't mention before, but this duplicates exactly the configuration of tests.yml (other than enabling coverage). What do you think: should we just rely on this workflow to run tests, and then limit the tests.yml job to run on pushes to main (instead of PRs?)

If we do that, then we will want to remove the `branches: main` limitation we added earlier in the coverage tests workflow (so we can still get runs on backport PRs).

hmm, we should be able to use that directly.
checking locally.

I don’t think so, this workflow is specifically meant to generate coverage reports for PR comments, and `coverage_comment.yml` only runs when the triggering workflow was a pull request. Push events wouldn’t generate PR comments anyway.

Got it, was just curious if the event would still trigger when pushing additional commits to a PR, looks like it should 👍

I see the duplication concern, but :
- The coverage workflow only triggers on `django/**/*.py` and `tests/**/*.py` changes - PRs that modify test fixtures, templates, or config files wouldn't run ANY tests.
- JavaScript tests would stop running on PRs.
- Could we instead just remove the branches: - main restriction from the coverage workflow (so backport PRs get coverage too) and accept the ~5 min duplication on PRs to main?
Or should we broaden the `paths` filter in the coverage workflow to match the `paths-ignore` behavior of `tests.yml`?"

don't know if I'm overthinking, but let me know.

> Or should we broaden the paths filter in the coverage workflow to match the paths-ignore behavior of tests.yml?"

I like this, it goes back in the direction you had originally but now we have a concrete reason to prefer it 👍 . There aren't so many PRs that only touch javascript.

Accessing `pull_requests[0]` without null-checking could result in `NaN` being passed to GitHub API calls. While the job condition requires `event == 'pull_request'`, the `pull_requests` array may be empty in edge cases;
and can lead to breaking subsequent API operations?

you think we need to catch this?

Yes, it's easy enough, let's null check.

Just leaving a comment for visibility -- we decided to limit this to push events to reduce the exact duplication (on PRs) of the test configuration with and without coverage enabled. We can revisit this once we're ready to start testing more configurations on github actions.


Fixed #36747 -- Added support for parsing weeks in django.utils.dateparse.parse_duration().

#### 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-36747

#### Branch description
Added support for parsing weeks in ISO 8601 duration strings (e.g., 'P4W') within django.utils.dateparse.parse_duration(). Previously, this would return None.

This PR also updates the docstrings and documentation to explicitly clarify that while ISO 8601 duration parsing is supported, years (Y) and months (M) remain unsupported

#### 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.

@@ -40,6 +40,7 @@
 iso8601_duration_re = _lazy_re_compile(
     r"^(?P<sign>[-+]?)"
     r"P"
+    r"(?:(?P<weeks>\d+([.,]\d+)?)W)?"
     r"(?:(?P<days>\d+([.,]\d+)?)D)?"
     r"(?:T"
     r"(?:(?P<hours>\d+([.,]\d+)?)H)?"
@@ -134,8 +135,8 @@ def parse_duration(value):

     The preferred format for durations in Django is '%d %H:%M:%S.%f'.

-    Also supports ISO 8601 representation and PostgreSQL's day-time interval
-    format.
+    Also supports ISO 8601 representation (excluding years and months) and
+    PostgreSQL's day-time interval format.
     """
     match = (
         standard_duration_re.match(value)

@@ -147,6 +147,15 @@ The functions defined in this module share the following properties:
     ``P4DT1H15M20S`` which is equivalent to ``4 1:15:20``) or PostgreSQL's
     day-time interval format (e.g. ``3 days 04:05:06``).

+    .. admonition:: Years and months not supported
+
+        ISO 8601 years (``Y``) and months (``M``) are unsupported as they
+        cannot be represented by :class:`~datetime.timedelta`.
+
+    .. versionchanged:: 6.1
+
+        ISO 8601 duration parsing now supports weeks (``PnW``).
+
 ``django.utils.decorators``
 ===========================

@@ -321,7 +321,8 @@ URLs
 Utilities
 ~~~~~~~~~

-* ...
+* :func:`~django.utils.dateparse.parse_duration` now supports ISO 8601
+  durations expressed in weeks (``PnW``).

 Validators
 ~~~~~~~~~~

@@ -194,7 +194,11 @@ def test_iso_8601(self):
         test_values = (
             ("P4Y", None),
             ("P4M", None),
-            ("P4W", None),
+            ("P4W", timedelta(weeks=4)),
+            ("P0.5W", timedelta(weeks=0.5)),
+            ("P0,5W", timedelta(weeks=0.5)),
+            ("-P0.5W", timedelta(weeks=-0.5)),
+            ("P1W1D", timedelta(weeks=1, days=1)),
             ("P4D", timedelta(days=4)),
             ("-P1D", timedelta(days=-1)),
             ("P0.5D", timedelta(hours=12)),

Thanks [USER_REDACTED] 👍 .

I just reclassified this to a new feature on Trac, could you add some docs?

Thanks for the updates ⭐

I pushed tiny edits to docs.

Please add a `versionchanged` directive and a release note.

I'll move some of this into the text above. The rule of thumb is that the versionchanged directive shouldn't contain any information we want to keep forever, as when 6.1 goes EOL, we drop this whole directive.

Ticket numbers only necessary for regressions in patch release notes.

Also, make sure to indent.

Using `~django` helps reduce the verbosity of the link text.


Fixed #36714 -- Fixed context sharing for signals.

#### 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-36714](https://code.djangoproject.com/ticket/36714)

#### Branch description

Async signals lose `ContextVar` state due to use of `asyncio.gather`. Fixed it by sharing a context between tasks.

Related MR - https://github.com/[USER_REDACTED]ngo/asgiref/pull/536

#### 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.

@@ -1,4 +1,5 @@
 import asyncio
+import contextvars
 import logging
 import threading
 import weakref
@@ -22,26 +23,45 @@ def _make_id(target):
 NO_RECEIVERS = object()

-async def _gather(*coros):
+def _restore_context(context):
+    """
+    Check for changes in contextvars, and set them to the current
+    context for downstream consumers.
+    """
+    for cvar in context:
+        cvalue = context.get(cvar)
+        try:
+            if cvar.get() != cvalue:
+                cvar.set(cvalue)
+        except LookupError:
+            cvar.set(cvalue)
+
+
+async def _run_parallel(*coros):
+    """
+    Execute multiple asynchronous coroutines in parallel,
+    sharing the current context between them.
+    """
+    context = contextvars.copy_context()
+
     if len(coros) == 0:
         return []

-    if len(coros) == 1:
-        return [await coros[0]]
-
     async def run(i, coro):
         results[i] = await coro

     try:
         async with asyncio.TaskGroup() as tg:
             results = [None] * len(coros)
             for i, coro in enumerate(coros):
-                tg.create_task(run(i, coro))
+                tg.create_task(run(i, coro), context=context)
         return results
     except BaseExceptionGroup as exception_group:
         if len(exception_group.exceptions) == 1:
             raise exception_group.exceptions[0]
         raise
+    finally:
+        _restore_context(context=context)

 class Signal:
@@ -233,7 +253,7 @@ def send(self, sender, **named):
         if async_receivers:

             async def asend():
-                async_responses = await _gather(
+                async_responses = await _run_parallel(
                     *(
                         receiver(signal=self, sender=sender, **named)
                         for receiver in async_receivers
@@ -275,6 +295,7 @@ async def asend(self, sender, **named):
         ):
             return []
         sync_receivers, async_receivers = self._live_receivers(sender)
+
         if sync_receivers:

             [USER_REDACTED]_to_async
@@ -290,14 +311,12 @@ def sync_send():
             async def sync_send():
                 return []

-        responses, async_responses = await _gather(
-            sync_send(),
-            _gather(
-                *(
-                    receiver(signal=self, sender=sender, **named)
-                    for receiver in async_receivers
-                )
-            ),
+        responses = await sync_send()
+        async_responses = await _run_parallel(
+            *(
+                receiver(signal=self, sender=sender, **named)
+                for receiver in async_receivers
+            )
         )
         responses.extend(zip(async_receivers, async_responses))
         return responses
@@ -362,7 +381,7 @@ async def asend_and_wrap_exception(receiver):
                 return response

             async def asend():
-                async_responses = await _gather(
+                async_responses = await _run_parallel(
                     *(
                         asend_and_wrap_exception(receiver)
                         for receiver in async_receivers
@@ -436,11 +455,9 @@ async def asend_and_wrap_exception(receiver):
                 return err
             return response

-        responses, async_responses = await _gather(
-            sync_send(),
-            _gather(
-                *(asend_and_wrap_exception(receiver) for receiver in async_receivers),
-            ),
+        responses = await sync_send()
+        async_responses = await _run_parallel(
+            *(asend_and_wrap_exception(receiver) for receiver in async_receivers),
         )
         responses.extend(zip(async_receivers, async_responses))
         return responses

@@ -1,3 +1,4 @@
+import contextvars
 from unittest import mock

 from asgiref.sync import markcoroutinefunction
@@ -645,3 +646,238 @@ async def test_asend_robust_only_async_receivers(self):

         result = await signal.asend_robust(self.__class__)
         self.assertEqual(result, [(async_handler, 1)])
+
+
+class TestReceiversContextVarsSharing(SimpleTestCase):
+    def setUp(self):
+        self.ctx_var = contextvars.ContextVar("test_var", default=0)
+
+        class CtxSyncHandler:
+            def __init__(self, ctx_var):
+                self.ctx_var = ctx_var
+                self.values = []
+
+            def __call__(self, **kwargs):
+                val = self.ctx_var.get()
+                self.ctx_var.set(val + 1)
+                self.values.append(self.ctx_var.get())
+                return self.ctx_var.get()
+
+        class CtxAsyncHandler:
+            def __init__(self, ctx_var):
+                self.ctx_var = ctx_var
+                self.values = []
+                markcoroutinefunction(self)
+
+            async def __call__(self, **kwargs):
+                val = self.ctx_var.get()
+                self.ctx_var.set(val + 1)
+                self.values.append(self.ctx_var.get())
+                return self.ctx_var.get()
+
+        self.CtxSyncHandler = CtxSyncHandler
+        self.CtxAsyncHandler = CtxAsyncHandler
+
+    async def test_asend_correct_contextvars_sharing_async_receivers(self):
+        handler1 = self.CtxAsyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        await signal.asend(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    async def test_asend_correct_contextvars_sharing_sync_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxSyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        await signal.asend(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    async def test_asend_correct_contextvars_sharing_mix_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        await signal.asend(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    async def test_asend_robust_correct_contextvars_sharing_async_receivers(self):
+        handler1 = self.CtxAsyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        await signal.asend_robust(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    async def test_asend_robust_correct_contextvars_sharing_sync_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxSyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        await signal.asend_robust(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    async def test_asend_robust_correct_contextvars_sharing_mix_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        await signal.asend_robust(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    def test_send_correct_contextvars_sharing_async_receivers(self):
+        handler1 = self.CtxAsyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        signal.send(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    def test_send_correct_contextvars_sharing_sync_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxSyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        signal.send(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    def test_send_correct_contextvars_sharing_mix_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        signal.send(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    def test_send_robust_correct_contextvars_sharing_async_receivers(self):
+        handler1 = self.CtxAsyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        signal.send_robust(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    def test_send_robust_correct_contextvars_sharing_sync_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxSyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        signal.send_robust(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)
+
+    def test_send_robust_correct_contextvars_sharing_mix_receivers(self):
+        handler1 = self.CtxSyncHandler(self.ctx_var)
+        handler2 = self.CtxAsyncHandler(self.ctx_var)
+        signal = dispatch.Signal()
+        signal.connect(handler1)
+        signal.connect(handler2)
+
+        # set custom value outer signal
+        self.ctx_var.set(1)
+
+        signal.send_robust(self.__class__)
+
+        self.assertEqual(len(handler1.values), 1)
+        self.assertEqual(len(handler2.values), 1)
+        self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3])
+        self.assertEqual(self.ctx_var.get(), 3)

Thanks [USER_REDACTED] 👍

Thank you!

I tried using `.subTest` to reduce the number of tests for `asend/asend_robust` and `send/send_robust`, but the error output became unreadable.

```
  File "/Users/m.havelya/work/django/django/test/testcases.py", line 87, in is_pickable
    pickle.loads(pickle.dumps(obj))
    ^^^^^^^^^^^^^^^

   ....

  File "/Users/m.havelya/work/django/django/test/testcases.py", line 325, in __getstate__
    if key in pickable_state or not is_pickable(value):
    ^^^^^^^
  File "/Users/m.havelya/work/django/django/test/testcases.py", line 87, in is_pickable
    pickle.loads(pickle.dumps(obj))
    ^^^^^^^^^^^^^^^
  File "/Users/m.havelya/work/django/django/test/testcases.py", line 325, in __getstate__
    if key in pickable_state or not is_pickable(value):
    ^^^^^^^
RecursionError: maximum recursion depth exceeded

----------------------------------------------------------------------
Ran 31 tests in 0.439s

FAILED (errors=1)
```

So I dropped the idea. Using separate tests is more readable.

Summary:

- Removed the `len(coros) == 1` optimization. It doesn’t appear to provide any real benefit.

```
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓
┃                        Benchmark ┃ Min     ┃ Max     ┃ Mean    ┃ Min (+)         ┃ Max (+)         ┃ Mean (+)        ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩
│ TaskGroup instead of create_task │ 0.001   │ 0.001   │ 0.001   │ 0.001 (1.1x)    │ 0.002 (-1.1x)   │ 0.001 (1.1x)    │
└──────────────────────────────────┴─────────┴─────────┴─────────┴─────────────────┴─────────────────┴─────────────────┘
```

- All coroutines now share the same context, which is restored to the current context after completion.

btw:

- `_restore_context` is just a copy-paste from the `asgiref` library

Should we update the docstrings for `asend()`/`asend_robust()` now? It reads, "If any receivers are asynchronous, they are grouped and executed concurrently...", which is a little bit ambiguous, but if "they" is "all the receivers", then they're not all concurrent anymore.

I see we're coping this from `asgiref`. Is that only because it's marked as private? We could ask Carlton if he minds us importing it and/or if he wants to make it public in the next release.

Never mind, looking at the context right before this, I guess "they" means the asynchronous ones, so this should be fine.

- Is that only because it's marked as private? - yes 😌

- We could ask Carlton if he minds us importing it and/or if he wants to make it public in the next release - it's much more robust to keep them separate

- Also, it would be nice to lift this up out of the function definition.

done 🙂


Fixed #32568 -- Replaced obvious mark_safe usages with SafeString for performance.

#### 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-32568

#### Branch description
I have replace `mark_safe` with `SafeString` .

#### Checklist
- [X] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [ ] 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.

@@ -25,7 +25,7 @@
 from django.urls import NoReverseMatch
 from django.utils import formats, timezone
 from django.utils.html import format_html
-from django.utils.safestring import mark_safe
+from django.utils.safestring import SafeString, mark_safe
 from django.utils.text import capfirst
 from django.utils.translation import gettext as _

@@ -103,11 +103,11 @@ def result_headers(cl):
             if field_name == "action_checkbox":
                 aria_label = _("Select all objects on this page for an action")
                 yield {
-                    "text": mark_safe(
+                    "text": SafeString(
                         f'<input type="checkbox" id="action-toggle" '
                         f'aria-label="{aria_label}">'
                     ),
-                    "class_attrib": mark_safe(' class="action-checkbox-column"'),
+                    "class_attrib": SafeString(' class="action-checkbox-column"'),
                     "sortable": False,
                 }
                 continue
@@ -256,7 +256,7 @@ def link_in_col(is_first, field_name, cl):
                     f, (models.DateField, models.TimeField, models.ForeignKey)
                 ):
                     row_classes.append("nowrap")
-        row_class = mark_safe(' class="%s"' % " ".join(row_classes))
+        row_class = SafeString(' class="%s"' % " ".join(row_classes))
         # If list_display_links not defined, add the link tag to the first
         # field
         if link_to_changelist:

@@ -397,7 +397,7 @@ def handle_word(
                     attrs=nofollow_attr,
                     url=trimmed,
                 )
-                return mark_safe(f"{lead}{middle}{trail}")
+                return SafeString(f"{lead}{middle}{trail}")
             else:
                 if safe_input:
                     return mark_safe(word)

[USER_REDACTED] 👋

The changes to the source look fine, but please revert the changes to tests. Then I can look in more detail. Also please retitle your commit & PR to match the contributing guidelines. Thanks.

Thanks, just a bit more to tidy up. Also, PR & commits should be capitalized in headline case and past tense:
```
Fixed #12345 -- Replaced ...
```

Thanks 👍

Thanks [USER_REDACTED], and thanks [USER_REDACTED] for the review.

There are still changes to test files here.

On second thought, can you revert this also? It's the only one that's not a a literal string (in other words, where we can't guarantee the type is exactly `str`. There could be a lazy object with a custom `replace` method that relies on this somehow.

thanks for catching this.  Yes there might case where title(value) could be None or

something could have been done but i'm not confident
```
def strip_p_tags(value):
    if value is None:
        return ""
    return SafeString(str(value).replace("<p>", "").replace("</p>", ""))
```