Fixed #24920 -- Added support for DecimalField with no precision.

@@ -359,21 +359,15 @@ def get_field_type(self, connection, table_name, row):
         if field_type in {"CharField", "TextField"} and row.collation:
             field_params["db_collation"] = row.collation

-        if field_type == "DecimalField":
-            if row.precision is None or row.scale is None:
-                field_notes.append(
-                    "max_digits and decimal_places have been guessed, as this "
-                    "database handles decimal fields as float"
-                )
-                field_params["max_digits"] = (
-                    row.precision if row.precision is not None else 10
-                )
-                field_params["decimal_places"] = (
-                    row.scale if row.scale is not None else 5
-                )
-            else:
-                field_params["max_digits"] = row.precision
-                field_params["decimal_places"] = row.scale
+        if field_type == "DecimalField" and (
+            # This can generate DecimalFields with only one of max_digits and
+            # decimal_fields specified. This configuration would be incorrect,
+            # but nothing more correct could be generated.
+            row.precision is not None
+            or row.scale is not None
+        ):
+            field_params["max_digits"] = row.precision
+            field_params["decimal_places"] = row.scale

         return field_type, field_params, field_notes

@@ -383,6 +383,9 @@ class BaseDatabaseFeatures:
     # Does the backend support unlimited character columns?
     supports_unlimited_charfield = False

+    # Does the backend support numeric columns with no precision?
+    supports_no_precision_decimalfield = False
+
     # Does the backend support native tuple lookups (=, >, <, IN)?
     supports_tuple_lookups = True

@@ -106,6 +106,12 @@ def __get__(self, instance, cls=None):
         return instance.__dict__["operators"]

+def _get_decimal_column(data):
+    if data["max_digits"] is None and data["decimal_places"] is None:
+        return "NUMBER"
+    return "NUMBER(%(max_digits)s, %(decimal_places)s)" % data
+
+
 class DatabaseWrapper(BaseDatabaseWrapper):
     vendor = "oracle"
     display_name = "Oracle"
@@ -125,7 +131,7 @@ class DatabaseWrapper(BaseDatabaseWrapper):
         "CharField": "NVARCHAR2(%(max_length)s)",
         "DateField": "DATE",
         "DateTimeField": "TIMESTAMP",
-        "DecimalField": "NUMBER(%(max_digits)s, %(decimal_places)s)",
+        "DecimalField": _get_decimal_column,
         "DurationField": "INTERVAL DAY(9) TO SECOND(6)",
         "FileField": "NVARCHAR2(%(max_length)s)",
         "FilePathField": "NVARCHAR2(%(max_length)s)",

@@ -79,6 +79,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     supports_json_negative_indexing = False
     supports_collation_on_textfield = False
     supports_on_delete_db_default = False
+    supports_no_precision_decimalfield = True
     test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
     django_test_expected_failures = {
         # A bug in Django/oracledb with respect to string handling (#23843).

@@ -194,14 +194,21 @@ def get_table_description(self, cursor, table_name):
                 comment,
             ) = field_map[name]
             name %= {}  # oracledb, for some reason, doubles percent signs.
+            if desc[1] == oracledb.NUMBER and desc[5] == -127 and desc[4] == 0:
+                # DecimalField with no precision.
+                precision = None
+                scale = None
+            else:
+                precision = desc[4] or 0
+                scale = desc[5] or 0
             description.append(
                 FieldInfo(
                     self.identifier_converter(name),
                     desc[1],
                     display_size,
                     desc[3],
-                    desc[4] or 0,
-                    desc[5] or 0,
+                    precision,
+                    scale,
                     *desc[6:],
                     default,
                     collation,

@@ -89,6 +89,12 @@ def _get_varchar_column(data):
     return "varchar(%(max_length)s)" % data

+def _get_decimal_column(data):
+    if data["max_digits"] is None and data["decimal_places"] is None:
+        return "numeric"
+    return "numeric(%(max_digits)s, %(decimal_places)s)" % data
+
+
 class DatabaseWrapper(BaseDatabaseWrapper):
     vendor = "postgresql"
     display_name = "PostgreSQL"
@@ -105,7 +111,7 @@ class DatabaseWrapper(BaseDatabaseWrapper):
         "CharField": _get_varchar_column,
         "DateField": "date",
         "DateTimeField": "timestamp with time zone",
-        "DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)",
+        "DecimalField": _get_decimal_column,
         "DurationField": "interval",
         "FileField": "varchar(%(max_length)s)",
         "FilePathField": "varchar(%(max_length)s)",

@@ -68,6 +68,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     supports_covering_indexes = True
     supports_stored_generated_columns = True
     supports_nulls_distinct_unique_constraints = True
+    supports_no_precision_decimalfield = True
     can_rename_index = True
     test_collations = {
         "deterministic": "C",

@@ -137,8 +137,10 @@ def get_table_description(self, cursor, table_name):
                 # display_size is always None on psycopg2.
                 line.internal_size if line.display_size is None else line.display_size,
                 line.internal_size,
-                line.precision,
-                line.scale,
+                # precision and scale are always 2^16 - 1 on psycopg2 for
+                # DecimalFields with no precision.
+                None if line.precision == 2**16 - 1 else line.precision,
+                None if line.scale == 2**16 - 1 else line.scale,
                 *field_map[line.name],
             )
             for line in cursor.description

@@ -55,6 +55,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     insert_test_table_with_defaults = 'INSERT INTO {} ("null") VALUES (1)'
     supports_default_keyword_in_insert = False
     supports_unlimited_charfield = True
+    supports_no_precision_decimalfield = True
     can_return_columns_from_insert = True
     can_return_rows_from_bulk_insert = True
     can_return_rows_from_update = True

@@ -1725,54 +1725,84 @@ def check(self, **kwargs):
         return errors

     def _check_decimal_places(self):
-        try:
-            decimal_places = int(self.decimal_places)
-            if decimal_places < 0:
-                raise ValueError()
-        except TypeError:
-            return [
-                checks.Error(
-                    "DecimalFields must define a 'decimal_places' attribute.",
-                    obj=self,
-                    id="fields.E130",
-                )
-            ]
-        except ValueError:
-            return [
-                checks.Error(
-                    "'decimal_places' must be a non-negative integer.",
-                    obj=self,
-                    id="fields.E131",
-                )
-            ]
+        if self.decimal_places is None:
+            if (
+                not connection.features.supports_no_precision_decimalfield
+                and "supports_no_precision_decimalfield"
+                not in self.model._meta.required_db_features
+            ):
+                return [
+                    checks.Error(
+                        "DecimalFields must define a 'decimal_places' attribute.",
+                        obj=self,
+                        id="fields.E130",
+                    )
+                ]
+            elif self.max_digits is not None:
+                return [
+                    checks.Error(
+                        "DecimalField’s max_digits and decimal_places must both "
+                        "be defined or both omitted.",
+                        obj=self,
+                        id="fields.E135",
+                    ),
+                ]
         else:
-            return []
+            try:
+                decimal_places = int(self.decimal_places)
+                if decimal_places < 0:
+                    raise ValueError()
+            except ValueError:
+                return [
+                    checks.Error(
+                        "'decimal_places' must be a non-negative integer.",
+                        obj=self,
+                        id="fields.E131",
+                    )
+                ]
+        return []

     def _check_max_digits(self):
-        try:
-            max_digits = int(self.max_digits)
-            if max_digits <= 0:
-                raise ValueError()
-        except TypeError:
-            return [
-                checks.Error(
-                    "DecimalFields must define a 'max_digits' attribute.",
-                    obj=self,
-                    id="fields.E132",
-                )
-            ]
-        except ValueError:
-            return [
-                checks.Error(
-                    "'max_digits' must be a positive integer.",
-                    obj=self,
-                    id="fields.E133",
-                )
-            ]
+        if self.max_digits is None:
+            if (
+                not connection.features.supports_no_precision_decimalfield
+                and "supports_no_precision_decimalfield"
+                not in self.model._meta.required_db_features
+            ):
+                return [
+                    checks.Error(
+                        "DecimalFields must define a 'max_digits' attribute.",
+                        obj=self,
+                        id="fields.E132",
+                    )
+                ]
+            elif self.decimal_places is not None:
+                return [
+                    checks.Error(
+                        "DecimalField’s max_digits and decimal_places must both "
+                        "be defined or both omitted.",
+                        obj=self,
+                        id="fields.E135",
+                    ),
+                ]
         else:
-            return []
+            try:
+                max_digits = int(self.max_digits)
+                if max_digits <= 0:
+                    raise ValueError()
+            except ValueError:
+                return [
+                    checks.Error(
+                        "'max_digits' must be a positive integer.",
+                        obj=self,
+                        id="fields.E133",
+                    )
+                ]
+        return []

     def _check_decimal_places_and_max_digits(self, **kwargs):
+        if self.decimal_places is None or self.max_digits is None:
+            return []
         if int(self.decimal_places) > int(self.max_digits):
             return [
                 checks.Error(

@@ -196,6 +196,8 @@ Model fields
 * **fields.E133**: ``max_digits`` must be a positive integer.
 * **fields.E134**: ``max_digits`` must be greater or equal to
   ``decimal_places``.
+* **fields.E135**: ``DecimalField``’s ``max_digits`` and ``decimal_places``
+  must both be defined or both omitted.
 * **fields.E140**: ``FilePathField``\s must have either ``allow_files`` or
   ``allow_folders`` set to True.
 * **fields.E150**: ``GenericIPAddressField``\s cannot have ``blank=True`` if

@@ -862,16 +862,22 @@ A fixed-precision decimal number, represented in Python by a
 :class:`~decimal.Decimal` instance. It validates the input using
 :class:`~django.core.validators.DecimalValidator`.

-Has the following **required** arguments:
+Has the following arguments:

 .. attribute:: DecimalField.max_digits

     The maximum number of digits allowed in the number. Note that this number
-    must be greater than or equal to ``decimal_places``.
+    must be greater than or equal to ``decimal_places``. It's always required
+    on MySQL because this database doesn't support numeric fields with no
+    precision. It's also required for all database backends when
+    :attr:`~DecimalField.decimal_places` is provided.

 .. attribute:: DecimalField.decimal_places

-    The number of decimal places to store with the number.
+    The number of decimal places to store with the number. It's always required
+    on MySQL because this database doesn't support numeric fields with no
+    precision. It's also required for all database backends when
+    :attr:`~DecimalField.max_digits` is provided.

 For example, to store numbers up to ``999.99`` with a resolution of 2 decimal
 places, you'd use::
@@ -895,6 +901,11 @@ when :attr:`~django.forms.Field.localize` is ``False`` or
     should also be aware of :ref:`SQLite limitations <sqlite-decimal-handling>`
     of decimal fields.

+.. versionchanged:: 6.1
+
+    Support for ``DecimalField`` with no precision was added on Oracle,
+    PostgreSQL, and SQLite.
+
 ``DurationField``
 -----------------

@@ -247,6 +247,11 @@ Models
   top-level or nested JSON ``null`` values. See
   :ref:`storing-and-querying-for-none` for usage examples and some caveats.

+* :attr:`DecimalField.max_digits <django.db.models.DecimalField.max_digits>`
+  and :attr:`DecimalField.decimal_places
+  <django.db.models.DecimalField.decimal_places>` are no longer required to be
+  set on Oracle, PostgreSQL, and SQLite.
+
 Pagination
 ~~~~~~~~~~

@@ -121,6 +121,15 @@ class Meta:
         required_db_features = {"supports_unlimited_charfield"}

+class DecimalFieldNoPrec(models.Model):
+    decimal_field_no_precision = models.DecimalField(
+        max_digits=None, decimal_places=None
+    )
+
+    class Meta:
+        required_db_features = {"supports_no_precision_decimalfield"}
+
+
 class UniqueTogether(models.Model):
     field1 = models.IntegerField()
     field2 = models.CharField(max_length=10)

@@ -202,6 +202,13 @@ def test_char_field_unlimited(self):
         output = out.getvalue()
         self.assertIn("char_field = models.CharField()", output)

+    [USER_REDACTED]("supports_no_precision_decimalfield")
+    def test_decimal_field_no_precision(self):
+        out = StringIO()
+        call_command("inspectdb", "inspectdb_decimalfieldnoprec", stdout=out)
+        output = out.getvalue()
+        self.assertIn("decimal_field_no_precision = models.DecimalField()", output)
+
     def test_number_field_types(self):
         """Test introspection of various Django field types"""
         assertFieldType = self.make_field_type_asserter()
@@ -228,13 +235,8 @@ def test_number_field_types(self):
             assertFieldType(
                 "decimal_field", "models.DecimalField(max_digits=6, decimal_places=1)"
             )
-        else:  # Guessed arguments on SQLite, see #5014
-            assertFieldType(
-                "decimal_field",
-                "models.DecimalField(max_digits=10, decimal_places=5)  "
-                "# max_digits and decimal_places have been guessed, "
-                "as this database handles decimal fields as float",
-            )
+        else:
+            assertFieldType("decimal_field", "models.DecimalField()")

         assertFieldType("float_field", "models.FloatField()")
         assertFieldType(

@@ -599,15 +599,16 @@ def test_fix_default_value_tz(self):

 [USER_REDACTED]_apps("invalid_models_tests")
-class DecimalFieldTests(SimpleTestCase):
-    def test_required_attributes(self):
+class DecimalFieldTests(TestCase):
+    def test_both_attributes_omitted(self):
         class Model(models.Model):
             field = models.DecimalField()

         field = Model._meta.get_field("field")
-        self.assertEqual(
-            field.check(),
-            [
+        if connection.features.supports_no_precision_decimalfield:
+            expected = []
+        else:
+            expected = [
                 Error(
                     "DecimalFields must define a 'decimal_places' attribute.",
                     obj=field,
@@ -618,6 +619,52 @@ class Model(models.Model):
                     obj=field,
                     id="fields.E132",
                 ),
+            ]
+        self.assertEqual(field.check(), expected)
+
+    def test_both_attributes_omitted_required_db_features(self):
+        class Model(models.Model):
+            field = models.DecimalField()
+
+            class Meta:
+                required_db_features = {"supports_no_precision_decimalfield"}
+
+        field = Model._meta.get_field("field")
+        self.assertEqual(field.check(databases=self.databases), [])
+
+    [USER_REDACTED]("supports_no_precision_decimalfield")
+    def test_only_max_digits_defined(self):
+        class Model(models.Model):
+            field = models.DecimalField(max_digits=13)
+
+        field = Model._meta.get_field("field")
+        self.assertEqual(
+            field.check(),
+            [
+                Error(
+                    "DecimalField’s max_digits and decimal_places must both "
+                    "be defined or both omitted.",
+                    obj=field,
+                    id="fields.E135",
+                ),
+            ],
+        )
+
+    [USER_REDACTED]("supports_no_precision_decimalfield")
+    def test_only_decimal_places_defined(self):
+        class Model(models.Model):
+            field = models.DecimalField(decimal_places=5)
+
+        field = Model._meta.get_field("field")
+        self.assertEqual(
+            field.check(),
+            [
+                Error(
+                    "DecimalField’s max_digits and decimal_places must both "
+                    "be defined or both omitted.",
+                    obj=field,
+                    id="fields.E135",
+                ),
             ],
         )

A couple of nitpicks/questions about edge-cases, but this looks good to me overall.

I see below that we have a check to ensure that we don't have one of `max_digits` and `decimal_places` set to `None` when the other is not `None`. I think it's worth at least a comment here that we're OK with generating such an invalid field, since there's nothing more correct we can do. Alternatively we could raise a warning or an error here, but maybe that's excessive for something that might never happen.

If only one of these was `None`, this check would raise a `TypeError`. Maybe this code isn't reachable in that case, but this version is safe even if it becomes reachable:

```suggestion
        if self.decimal_places is None or self.max_digits is None:
```

Two lines above this we have `Has the following **required** arguments:`. I think `Has the following arguments:` is now more accurate.

Great catch :dart:

Added a comment.

I think it's a bit clearer to distribute the `not` here:

```suggestion
            if (
                not connection.features.supports_no_precision_decimalfield
                and "supports_no_precision_decimalfield"
                not in self.model._meta.required_db_features
            ):
```

And here:

```suggestion
            if (
                not connection.features.supports_no_precision_decimalfield
                and "supports_no_precision_decimalfield"
                not in self.model._meta.required_db_features
            ):
```

I believe this should be a database dependent check similar to `_check_db_collation()`, not only examining the default database.

Agreed, checking `connection` directly prevents mixing different database backends.

Thanks for the suggestion :+1: WiP


Fixed #36704 -- Fixed check errors for proxy subclass with a composite pk.

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

#### Branch description

Proxy models subclassing a model with a CompositePrimaryKey were
incorrectly reporting check errors because the check that requires only
local fields to be used in a composite pk was evaluated against the proxy
subclass, which has no fields.

To fix this we now do not evaluate any composite pk field checks against
proxy subclasses, as none of the checks are applicable to proxy
subclasses. This also has the benefit of not double-reporting real check
errors from an invalid superclass pk.

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

@@ -1801,7 +1801,7 @@ def _check_composite_pk(cls):
         meta = cls._meta
         pk = meta.pk

-        if not isinstance(pk, CompositePrimaryKey):
+        if meta.proxy or not isinstance(pk, CompositePrimaryKey):
             return errors

         seen_columns = defaultdict(list)

@@ -16,3 +16,6 @@ Bugfixes
 * Fixed a bug in Django 5.2 where ``QuerySet.first()`` and ``QuerySet.last()``
   raised an error on querysets performing aggregation that selected all fields
   of a composite primary key.
+
+* Fixed a bug in Django 5.2 where proxy models having a ``CompositePrimaryKey``
+  incorrectly raised a ``models.E042`` system check error.

@@ -268,3 +268,38 @@ class Bar(Foo):
                 ),
             ],
         )
+
+    def test_proxy_model_can_subclass_model_with_composite_pk(self):
+        class Foo(models.Model):
+            pk = models.CompositePrimaryKey("a", "b")
+            a = models.SmallIntegerField()
+            b = models.SmallIntegerField()
+
+        class Bar(Foo):
+            class Meta:
+                proxy = True
+
+        self.assertEqual(Foo.check(databases=self.databases), [])
+        self.assertEqual(Bar.check(databases=self.databases), [])
+
+    def test_proxy_model_does_not_check_superclass_composite_pk_errors(self):
+        class Foo(models.Model):
+            pk = models.CompositePrimaryKey("a", "b")
+            a = models.SmallIntegerField()
+
+        class Bar(Foo):
+            class Meta:
+                proxy = True
+
+        self.assertEqual(
+            Foo.check(databases=self.databases),
+            [
+                checks.Error(
+                    "'b' cannot be included in the composite primary key.",
+                    hint="'b' is not a valid field.",
+                    obj=Foo,
+                    id="models.E042",
+                ),
+            ],
+        )
+        self.assertEqual(Bar.check(databases=self.databases), [])

Thanks [USER_REDACTED] for the PR 🥇

Since the ticket is a release blocker, you'll need to add a release note to `5.2.8.txt`.

Thanks [USER_REDACTED] and [USER_REDACTED] for the review! 🎉

I pushed only tiny edits to the note.

```suggestion
* Fixed a bug where proxy subclasses of models using a ``CompositePrimaryKey``
  incorrectly raised ``models.E042`` system check errors for fields in the
  primary key (:ticket:`36704`).
```
maybe


Fixes #36705 -- Optimized string concatenation in utils.

#### Trac ticket number

ticket-36705

#### Branch description

This patch fixes the quadratic string concatenation issue described in ticket #36705, which is especially slow on PyPy.

#### 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" ticket flag in the Trac system.
  - [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.
  - [ ] I have added or updated relevant tests.

@@ -4,6 +4,7 @@
 import json
 import re
 import warnings
+from collections import deque
 from collections.abc import Mapping
 from html.parser import HTMLParser
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit, urlunsplit
@@ -429,7 +430,7 @@ def trim_punctuation(self, word):
         # Strip all opening wrapping punctuation.
         middle = word.lstrip(self.wrapping_punctuation_openings)
         lead = word[: len(word) - len(middle)]
-        trail = ""
+        trail = deque()

         # Continue trimming until middle remains unchanged.
         trimmed_something = True
@@ -442,7 +443,7 @@ def trim_punctuation(self, word):
                     rstripped = middle.rstrip(closing)
                     if rstripped != middle:
                         strip = counts[closing] - counts[opening]
-                        trail = middle[-strip:]
+                        trail.appendleft(middle[-strip:])
                         middle = middle[:-strip]
                         trimmed_something = True
                         counts[closing] -= strip
@@ -453,7 +454,7 @@ def trim_punctuation(self, word):
             else:
                 rstripped = middle.rstrip(self.trailing_punctuation_chars_no_semicolon)
             if rstripped != middle:
-                trail = middle[len(rstripped) :] + trail
+                trail.appendleft(middle[len(rstripped) :])
                 middle = rstripped
                 trimmed_something = True

@@ -470,13 +471,14 @@ def trim_punctuation(self, word):
                         # entity.
                         recent_semicolon = middle[trail_start:].index(";")
                         middle_semicolon_index = recent_semicolon + trail_start + 1
-                        trail = middle[middle_semicolon_index:] + trail
+                        trail.appendleft(middle[middle_semicolon_index:])
                         middle = rstripped + middle[trail_start:middle_semicolon_index]
                     else:
-                        trail = middle[trail_start:] + trail
+                        trail.appendleft(middle[trail_start:])
                         middle = rstripped
                     trimmed_something = True

+        trail = "".join(trail)
         return lead, middle, trail

     [USER_REDACTED]

@@ -169,11 +169,11 @@ def int_to_base36(i):
         raise ValueError("Negative base36 conversion input.")
     if i < 36:
         return char_set[i]
-    b36 = ""
+    b36_parts = []
     while i != 0:
         i, n = divmod(i, 36)
-        b36 = char_set[n] + b36
-    return b36
+        b36_parts.append(char_set[n])
+    return "".join(reversed(b36_parts))

 def urlsafe_base64_encode(s):

@@ -91,15 +91,15 @@ def format(
             # grouping is a single value
             intervals = [grouping, 0]
         active_interval = intervals.pop(0)
-        int_part_gd = ""
+        int_part_gd = []
         cnt = 0
         for digit in int_part[::-1]:
             if cnt and cnt == active_interval:
                 if intervals:
                     active_interval = intervals.pop(0) or active_interval
-                int_part_gd += thousand_sep[::-1]
+                int_part_gd.append(thousand_sep[::-1])
                 cnt = 0
-            int_part_gd += digit
+            int_part_gd.append(digit)
             cnt += 1
-        int_part = int_part_gd[::-1]
+        int_part = "".join(int_part_gd)[::-1]
     return sign + int_part + dec_part

@@ -103,7 +103,7 @@ class TruncationCompleted(Exception):
     def __init__(self, *, length, replacement, convert_charrefs=True):
         super().__init__(convert_charrefs=convert_charrefs)
         self.tags = deque()
-        self.output = ""
+        self.output = []
         self.remaining = length
         self.replacement = replacement

@@ -119,13 +119,13 @@ def handle_startendtag(self, tag, attrs):
             self.handle_endtag(tag)

     def handle_starttag(self, tag, attrs):
-        self.output += self.get_starttag_text()
+        self.output.append(self.get_starttag_text())
         if tag not in self.void_elements:
             self.tags.appendleft(tag)

     def handle_endtag(self, tag):
         if tag not in self.void_elements:
-            self.output += f"</{tag}>"
+            self.output.append(f"</{tag}>")
             try:
                 self.tags.remove(tag)
             except ValueError:
@@ -136,16 +136,16 @@ def handle_data(self, data):
         data_len = len(data)
         if self.remaining < data_len:
             self.remaining = 0
-            self.output += add_truncation_text(output, self.replacement)
+            self.output.append(add_truncation_text(output, self.replacement))
             raise self.TruncationCompleted
         self.remaining -= data_len
-        self.output += output
+        self.output.append(output)

     def feed(self, data):
         try:
             super().feed(data)
         except self.TruncationCompleted:
-            self.output += "".join([f"</{tag}>" for tag in self.tags])
+            self.output.extend([f"</{tag}>" for tag in self.tags])
             self.tags.clear()
             self.reset()
         else:
@@ -166,9 +166,9 @@ def __init__(self, *, length, replacement, convert_charrefs=True):
     def process(self, data):
         self.processed_chars += len(data)
         if (self.processed_chars == self.length) and (
-            len(self.output) + len(data) == len(self.rawdata)
+            sum(len(p) for p in self.output) + len(data) == len(self.rawdata)
         ):
-            self.output += data
+            self.output.append(data)
             raise self.TruncationCompleted
         output = escape("".join(data[: self.remaining]))
         return data, output
@@ -213,7 +213,7 @@ def chars(self, num, truncate=None, html=False):
             parser = TruncateCharsHTMLParser(length=length, replacement=truncate)
             parser.feed(text)
             parser.close()
-            return parser.output
+            return "".join(parser.output)
         return self._text_chars(length, truncate, text)

     def _text_chars(self, length, truncate, text):
@@ -250,7 +250,7 @@ def words(self, num, truncate=None, html=False):
             parser = TruncateWordsHTMLParser(length=length, replacement=truncate)
             parser.feed(self._wrapped)
             parser.close()
-            return parser.output
+            return "".join(parser.output)
         return self._text_words(length, truncate)

     def _text_words(self, length, truncate):

@@ -426,6 +426,12 @@ Django is compatible with versions of PyPy corresponding to the supported
 Python versions, but you will need to check the compatibility of other
 libraries you rely on.

+That said, a lot of a web framework's work is done by concatenating
+strings, and PyPy has an issue with that (see
+`this PyPy blog
+<https://pypy.org/posts/2023/01/string-concatenation-quadratic.html>`_).
+This may cause performance issues, depending on your use.
+
 C implementations of Python libraries
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Thanks [USER_REDACTED]. Can you take a look at the test failures and then mark this ready for review?

Thanks!

Please use the link syntax found elsewhere in this doc.

I get appending and finally reversing to avoid `insert()`, but in that case I think we need to update the `b36` variable name.

I think I'd leave the blank line here.

This seems like a wrong transformation - we don't want to append, but fully replace…? So more like:

```python
trail.clear()
trail.append(middle[-strip:])
```

Thanks [USER_REDACTED], good eye.

Although this is a logic change, I'm wondering if it fixed a bug.

Before (i.e. with #20045):
```py
>>> Urlizer()("([https://djangoproject.com])")
'([<a href="https://djangoproject.com">https://djangoproject.com</a>])'
```

With adding `trail.clear()` to "fix" the accidental change, wrong result, missing final ")":
```py
>>> Urlizer()("([https://djangoproject.com])")
'([<a href="https://djangoproject.com">https://djangoproject.com</a>]'
```

The missing trailing character is also missing on stable/6.0.x

Do you agree we should add a test case to capture?


Fixed #36652 -- Increased determinism when loading migrations from disk.

#### Trac ticket number
ticket-36652

#### Branch description
Ordering still depends on pkgutil.iter_modules, which does not guarantee order, but at least now Django is not introducing additional indeterminism, causing CircularDependencyError to appear or not appear in some edge cases.

Continued from #19973

Patch by [USER_REDACTED]

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

@@ -109,11 +109,11 @@ def load_disk(self):
                 if was_loaded:
                     reload(module)
             self.migrated_apps.add(app_config.label)
-            migration_names = {
+            migration_names = [
                 name
                 for _, name, is_pkg in pkgutil.iter_modules(module.__path__)
                 if not is_pkg and name[0] not in "_~"
-            }
+            ]
             # Load migrations
             for migration_name in migration_names:
                 migration_path = "%s.%s" % (module_name, migration_name)

@@ -1,7 +1,12 @@
 import compileall
 import os
+import subprocess
+import sys
+import tempfile
 from importlib import import_module
+from pathlib import Path

+from django.conf import settings
 from django.db import connection, connections
 from django.db.migrations.exceptions import (
     AmbiguityError,
@@ -649,6 +654,70 @@ def test_loading_package_without__file__(self):
             test_module.__spec__.origin = module_origin
             test_module.__spec__.has_location = module_has_location

+    def test_loading_order_does_not_create_circular_dependency(self):
+        """
+        Before, for these migrations:
+        app1
+        [ ] 0001_squashed_initial <- replaces app1.0001
+        [ ] 0002_squashed_initial <- replaces app1.0001
+            depends on app1.0001_squashed_initial & app2.0001_squashed_initial
+        app2
+        [ ] 0001_squashed_initial <- replaces app2.0001
+
+        When loading app1's migrations, if 0002_squashed_initial was first:
+        {'0002_squashed_initial', '0001_initial', '0001_squashed_initial'}
+        [NAME_REDACTED] was raised, but it's resolvable as:
+        {'0001_initial', '0001_squashed_initial', '0002_squashed_initial'}
+        """
+        # Create a test settings file to provide to the subprocess.
+        MIGRATION_MODULES = {
+            "app1": "migrations.test_migrations_squashed_replaced_order.app1",
+            "app2": "migrations.test_migrations_squashed_replaced_order.app2",
+        }
+        INSTALLED_APPS = [
+            "migrations.test_migrations_squashed_replaced_order.app1",
+            "migrations.test_migrations_squashed_replaced_order.app2",
+        ]
+        tests_dir = Path(__file__).parent.parent
+        with tempfile.NamedTemporaryFile(
+            mode="w", encoding="utf-8", suffix=".py", dir=tests_dir, delete=False
+        ) as test_settings:
+            for attr, value in settings._wrapped.__dict__.items():
+                if attr.isupper():
+                    test_settings.write(f"{attr} = {value!r}\n")
+            # Provide overrides here, instead of via decorators.
+            test_settings.write(f"DATABASES = {settings.DATABASES}\n")
+            test_settings.write(f"MIGRATION_MODULES = {MIGRATION_MODULES}\n")
+            # Isolate away other test apps.
+            test_settings.write(
+                "INSTALLED_APPS=[a for a in INSTALLED_APPS if a.startswith('django')]\n"
+            )
+            test_settings.write(f"INSTALLED_APPS += {INSTALLED_APPS}\n")
+        test_settings_name = test_settings.name
+        self.addCleanup(os.remove, test_settings_name)
+
+        test_environ = os.environ.copy()
+        test_environ["PYTHONPATH"] = str(tests_dir)
+        # Ensure deterministic failures.
+        test_environ["PYTHONHASHSEED"] = "1"
+
+        args = [
+            sys.executable,
+            "-m",
+            "django",
+            "showmigrations",
+            "app1",
+            "--skip-checks",
+            "--settings",
+            Path(test_settings_name).stem,
+        ]
+        try:
+            subprocess.run(
+                args, capture_output=True, env=test_environ, check=True, text=True
+            )
+        except subprocess.CalledProcessError as err:
+            self.fail(err.stderr)
+

 class PycLoaderTests(MigrationTestBase):
     def test_valid(self):

@@ -0,0 +1,9 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = []
+
+    operations = []

@@ -0,0 +1,13 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    replaces = [
+        ("app1", "0001_initial"),
+    ]
+
+    dependencies = []
+
+    operations = []

@@ -0,0 +1,16 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    replaces = [
+        ("app1", "0001_initial"),
+    ]
+
+    dependencies = [
+        ("app1", "0001_squashed_initial"),
+        ("app2", "0001_squashed_initial"),
+    ]
+
+    operations = []

@@ -0,0 +1,11 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = [
+        ("app1", "0001_initial"),
+    ]
+
+    operations = []

@@ -0,0 +1,13 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    replaces = [
+        ("app2", "0001_initial"),
+    ]
+
+    dependencies = []
+
+    operations = []

I considered making this `sorted()`, but I wanted to take the lightest possible touch for now, and not risk making a Django 6.1 upgrade any more difficult for anyone.

This is a bit brittle. I don't know that it's worth accounting for, but for MongoDB we have this patch:
```diff
diff --git a/tests/runtests.py b/tests/runtests.py
index 500908978a..b72b9b7de1 100755
--- a/tests/runtests.py
+++ b/tests/runtests.py
@@ -14,6 +14,7 @@ import warnings
 from pathlib import Path

 import django_mongodb_backend
+from bson import ObjectId

 try:
     import django
@@ -247,7 +248,7 @@ def setup_collect_tests(start_at, start_after, test_labels=None):
         }
     ]
     settings.LANGUAGE_CODE = "en"
-    settings.SITE_ID = 1
+    settings.SITE_ID = ObjectId("000000000000000000000001")
     settings.MIDDLEWARE = ALWAYS_MIDDLEWARE
     settings.MIGRATION_MODULES = {
         # This lets us skip creating migrations for the test models as many of
```
The written migration file doesn't have the necessary import:
```
  File "/home/tim/code/django/tests/tmpf6bu1e4h.py", line 151, in <module>
    SITE_ID = ObjectId('000000000000000000000001')
              ^^^^^^^^
NameError: name 'ObjectId' is not defined. Did you mean: 'object'?
```

Ah. A prior iteration tried to only write the few settings that appeared relevant for the test, until the geodjango tests revealed that the geo settings needed to be present in order connect to the db. I'll give it some thought. Maybe we can only write out settings values consisting of literals and end up with something slightly less brittle.


Fixed #36702 -- Fixed bulk_create to correctly return a pk set as an expression.

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

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

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] 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.

@@ -840,8 +840,7 @@ def bulk_create(
                 )
                 for obj_with_pk, results in zip(objs_with_pk, returned_columns):
                     for result, field in zip(results, opts.db_returning_fields):
-                        if field != opts.pk:
-                            setattr(obj_with_pk, field.attname, result)
+                        setattr(obj_with_pk, field.attname, result)
                 for obj_with_pk in objs_with_pk:
                     obj_with_pk._state.adding = False
                     obj_with_pk._state.db = self.db

@@ -884,6 +884,15 @@ def test_db_default_primary_key(self):
         (obj,) = DbDefaultPrimaryKey.objects.bulk_create([DbDefaultPrimaryKey()])
         self.assertIsInstance(obj.id, datetime)

+    [USER_REDACTED](
+        "can_return_rows_from_bulk_insert", "supports_expression_defaults"
+    )
+    def test_db_expression_primary_key(self):
+        (obj,) = DbDefaultPrimaryKey.objects.bulk_create(
+            [DbDefaultPrimaryKey(id=Now())]
+        )
+        self.assertIsInstance(obj.id, datetime)
+

 [USER_REDACTED]("supports_transactions", "has_bulk_insert")
 class BulkCreateTransactionTests(TransactionTestCase):

Thanks for your second patch Johannes! The test will need slight adjustment to account for backends that `can_return_rows_from_bulk_insert` but do not `supports_expression_defaults`

Use `State` instead as the `DbDefaultPrimaryKey` model will only be created on backends that `supports_expression_defaults`.

```suggestion
    def test_db_expression_primary_key(self):
        (obj,) = State.objects.bulk_create(
            [State(two_letter_code=Value("CA"))]
        )
        self.assertIsInstance(obj.two_letter_code, str)
```

With your suggested test my fix doesn't work, because `two_letter_code` is not in `State._meta.db_returning_fields` because it doesn't have a `db_default`.

I see, so the follow up will address this case as well.

Make sure to include `"supports_expression_defaults"` in the `skipUnlessDBFeature` instead then.

Fixing the issue for the more general case would require to allow entirely overriding or at least specifying additional returning fields through an additional parameter of `bulk_create`. This way also expressions specified for other non-primary key fields could easily be returned.

> This way also expressions specified for other non-primary key fields could easily be returned.

I think there might be a way to efficiently flag all fields that have resolvable/compilable values assigned to them and automatically augment `returning_fields` with them so it can be done implicitly.

That's [the approach we've recently taken with `Model.save` for example](https://github.com/[USER_REDACTED]ngo/django/commit/94680437a45a71c70ca8bd2e68b72aa1e2eff337#diff-1c8b882c73bfda668d5451d4578c97191b0ebc0f088d0c0ba2296ab89b428c44R1105-R1158).

That would be smart. Still a worst case scenario could be a `bulk_create` call with many objects on a model that has many fields, most of them specified as expressions, none of which are actually needed afterwards. It would be both unnecessary to look at all field values for all objects to check if they are expressions, and also to fetch all values again from the db. For such a case it would still be nice to override the returning fields manually to regain the little extra performance.

> It would be both unnecessary to look at all field values for all objects to check if they are expressions

The thing is we already have to do that in order to call `resolve_expression` and `as_sql` on them and generate the proper SQL. See how `SQLInsertCompiler` does it.

> also to fetch all values again from the db. For such a case it would still be nice to override the returning fields manually to regain the little extra performance.

I do agree that granular control over `returning_fields` is desirable as even without implicitly `RETURNING` any field that makes use of expressions, as suggested above, the overfetching problem is already present for `db_returning` fields such as the ones that define `db_default` or are `GeneratedField`. You might not necessarily want fetch back db generated value on each `bulk_create` and in some cases the `pk` might not even be of interest.

I think we agree that in an ideal state `returning_fields` could be overridable and it would default to `db_returning` fields + fields that had at least one instance with a provided expression.


Added community package email backends mention to docs.

#### Trac ticket number
N/A

#### Branch description
This adds an admonition to the email documentation about the community packages. It references both the [NAME_REDACTED] page and the [NAME_REDACTED] grid.

This is related to https://github.com/[USER_REDACTED]ngo/new-features/issues/39

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

@@ -2767,15 +2767,6 @@ backend definition in :setting:`STORAGES`.
     Defining this setting overrides the default value and is *not* merged with
     it.

-.. admonition:: There are community maintained solutions too!
-
-    Django has a vibrant ecosystem. There are storage backends
-    highlighted on the `[NAME_REDACTED]`_ page. The Django Packages
-    `[NAME_REDACTED] grid`_ has even more options for you!
-
-.. _Community Ecosystem: https://www.djangoproject.com/community/ecosystem/#storage-and-static-files
-.. _Storage Backends grid: https://djangopackages.org/grids/g/storage-backends/
-
 .. setting:: TASKS

 ``TASKS``

@@ -773,6 +773,16 @@ specify this backend, put the following in your settings::
 This backend is not intended for use in production -- it is provided as a
 convenience that can be used during development.

+.. admonition:: There are community-maintained solutions too!
+
+    Django has a vibrant ecosystem. There are email backends
+    highlighted on the `[NAME_REDACTED]`_ page. The Django Packages
+    `Email grid`_ has even more options for you!
+
+.. _Community Ecosystem: https://www.djangoproject.com/community/ecosystem/#email-and-notifications
+.. _Email grid: https://djangopackages.org/grids/g/email/
+
+
 .. _topic-custom-email-backend:

 Defining a custom email backend

Thanks [USER_REDACTED] 👍🏾

Thanks!

I'm on the fence about this particular mention.

```suggestion
.. admonition:: There are community-maintained solutions too!
```
or maybe
```
.. admonition:: Community-maintained backend solutions
```

Remember to remove this 😅

😮💨 Welp that's embarrassing 😀


Fixed #36696 -- [NAME_REDACTED] with PY314 when DEBUG=True

#### Trac ticket number

ticket-36696

#### Branch description

This PR resolves `NameError`s with Python 3.14 when using annotations guarded by `if TYPE_CHECKING` in application code due to deferred evaluation.

With `DEBUG=True`, Django will perform inspection of signals and templatetags during registration to check if they accept kwargs etc. With Python 3.14, this triggers evaluation of annotations for these signals or templatetags, which may not be possible if the annotations are (as usual) written for static type checkers only, leading to `NameError`. This PR avoids this by using `annotation_format=annotationlib.Format.FORWARDREF`, which is available since Python 3.14.

#### 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" 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,10 +1,24 @@
 import functools
 import inspect

+from django.utils.version import PY314
+
+if PY314:
+    import annotationlib
+

 [USER_REDACTED].lru_cache(maxsize=512)
 def _get_func_parameters(func, remove_first):
-    parameters = tuple(inspect.signature(func).parameters.values())
+    # As the annotations are not used in any case, inspect the signature with
+    # FORWARDREF to leave any deferred annotations unevaluated.
+    if PY314:
+        signature = inspect.signature(
+            func, annotation_format=annotationlib.Format.FORWARDREF
+        )
+    else:
+        signature = inspect.signature(func)
+
+    parameters = tuple(signature.parameters.values())
     if remove_first:
         parameters = parameters[1:]
     return parameters

@@ -1,8 +1,13 @@
 import subprocess
 import unittest
+from typing import TYPE_CHECKING

 from django.shortcuts import aget_object_or_404
 from django.utils import inspect
+from django.utils.version import PY314
+
+if TYPE_CHECKING:
+    from django.utils.safestring import SafeString

 class Person:
@@ -103,6 +108,16 @@ def test_func_accepts_kwargs(self):
         self.assertIs(inspect.func_accepts_kwargs(Person.all_kinds), True)
         self.assertIs(inspect.func_accepts_kwargs(Person().just_args), False)

+    [USER_REDACTED].skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
+    def test_func_accepts_kwargs_deferred_annotations(self):
+
+        def func_with_annotations(self, name: str, complex: SafeString) -> None:
+            pass
+
+        # Inspection fails with deferred annotations with python 3.14+. Earlier
+        # Python versions trigger the NameError on module initialization.
+        self.assertIs(inspect.func_accepts_kwargs(func_with_annotations), False)
+

 class IsModuleLevelFunctionTestCase(unittest.TestCase):
     [USER_REDACTED]

Thanks again for the report! I pushed tiny edits. Keep the 3.14 issues coming! 🏆

Use descriptive name instead of the ticket number, e.g.
```suggestion
    def test_func_accepts_kwargs_deferred_annotations(self):
```

Use `skipUnless` decorator instead.

We normally don't include links to tickets in test comments, and avoid using `we` and similar. Maybe:
```
        # Inspection fails with deferred annotations with python 3.14+. Earlier
        # Python versions trigger the NameError on module initialization.
```

I'm not sure if release notes are needed. `5.2.8` is the first version that officially supports Python 3.14, so we still have several days to fix bugs like that.

Fixed, thank you

Uhh, I did not know about this feature before, nice to know :)

Done

I am happy both ways, maybe I read too fast when [USER_REDACTED] suggested backporting. Just tell me if it should be removed and I'll recommit.

It will be backported, but this bug does not exist in any released version of Django that claims support for Python 3.14, so release notes shouldn't be necessary.

Sure, removed the release notes

```suggestion
    [USER_REDACTED].skipUnless(PY314, "Deferred annotations are Python 3.14+ only")
```

Thanks, fixed


Fixed #36321 -- Added argparse suggest_on_error support for Python 3.14+

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

#### Branch description
This PR enables `argparse's suggest_on_error feature` for Django management commands when running on Python 3.14 or higher. This provides helpful suggestions when users mistype command arguments.

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

@@ -15,6 +15,7 @@
 from django.core.exceptions import ImproperlyConfigured
 from django.core.management.color import color_style, no_style
 from django.db import DEFAULT_DB_ALIAS, connections
+from django.utils.version import PY314, PY315

 ALL_CHECKS = "__all__"

@@ -57,6 +58,8 @@ def __init__(
     ):
         self.missing_args_message = missing_args_message
         self.called_from_command_line = called_from_command_line
+        if PY314 and not PY315:
+            kwargs.setdefault("suggest_on_error", True)
         super().__init__(**kwargs)

     def parse_args(self, args=None, namespace=None):

@@ -20,6 +20,7 @@
 PY312 = sys.version_info >= (3, 12)
 PY313 = sys.version_info >= (3, 13)
 PY314 = sys.version_info >= (3, 14)
+PY315 = sys.version_info >= (3, 15)

 def get_version(version=None):

@@ -228,7 +228,9 @@ [NAME_REDACTED]
 ~~~~~~~~~~~~~~~~~~~

-* ...
+* Management commands now set :class:`~argparse.ArgumentParser`\'s
+  ``suggest_on_error`` argument to ``True`` by [NAME_REDACTED] 3.14, enabling
+  suggestions for mistyped subcommand names and argument choices.

 Migrations
 ~~~~~~~~~~

@@ -38,7 +38,7 @@
 from django.test import LiveServerTestCase, SimpleTestCase, TestCase, override_settings
 from django.test.utils import captured_stderr, captured_stdout
 from django.urls import path
-from django.utils.version import PY313, get_docs_version
+from django.utils.version import PY313, PY314, get_docs_version
 from django.views.static import serve

 from . import urls
@@ -2446,10 +2446,16 @@ def test_precedence(self):

 class CommandDBOptionChoiceTests(SimpleTestCase):
     def test_invalid_choice_db_option(self):
-        expected_error = (
-            r"Error: argument --database: invalid choice: 'deflaut' "
-            r"\(choose from '?default'?, '?other'?\)"
-        )
+        if PY314:
+            expected_error = (
+                r"Error: argument --database: invalid choice: 'deflaut', "
+                r"maybe you meant 'default'\? \(choose from default, other\)"
+            )
+        else:
+            expected_error = (
+                r"Error: argument --database: invalid choice: 'deflaut' "
+                r"\(choose from '?default'?, '?other'?\)"
+            )
         args = [
             "changepassword",
             "createsuperuser",

@@ -1,5 +1,6 @@
 import os
 import sys
+import unittest
 from argparse import ArgumentDefaultsHelpFormatter
 from io import BytesIO, StringIO, TextIOWrapper
 from pathlib import Path
@@ -24,6 +25,7 @@
 from django.test import SimpleTestCase, override_settings
 from django.test.utils import captured_stderr, extend_sys_path
 from django.utils import translation
+from django.utils.version import PY314, PY315

 from .management.commands import dance
 from .utils import AssertFormatterFailureCaughtContext
@@ -454,6 +456,20 @@ def test_outputwrapper_flush(self):
         self.assertIn("Working...", out.getvalue())
         self.assertIs(mocked_flush.called, True)

+    [USER_REDACTED].skipUnless(PY314 and not PY315, "Only relevant for Python 3.14")
+    def test_suggest_on_error_defaults_true(self):
+        command = BaseCommand()
+        parser = command.create_parser("prog_name", "subcommand")
+        self.assertTrue(parser.suggest_on_error)
+
+    [USER_REDACTED].skipUnless(PY314 and not PY315, "Only relevant for Python 3.14")
+    def test_suggest_on_error_explicit_false(self):
+        command = BaseCommand()
+        parser = command.create_parser(
+            "prog_name", "subcommand", suggest_on_error=False
+        )
+        self.assertFalse(parser.suggest_on_error)
+

 class CommandRunTests(AdminScriptTestCase):
     """

Thank you for working on this! Go go Djangonauts!

We'd also like to affect the `parser` created in `django/core/management/__init__.py:ManagementUtility.execute`, for `manage.py` usage.

Also, we need to ensure subparsers are affected, as created in `CommandParser.add_subparsers()`.

I think it might be better to hoist defaulting the argument on in `CommandParser.__init__()`, so all call sites are affected.

Thanks for iterating. Here is another round of review!

I think I misunderstood the feature when writing up the ticket, and that's carried on in your implementation. Take a look at my comment in the test file. It would be good if you could also run a management command locally on Python 3.14+ with this branch, to see the suggestions working.

Looking good, just one last detail.

Thanks [USER_REDACTED]! I rebased and pushed only minor edits to comments. Welcome aboard ⛵ !

Use `PY314` from `django.utils.version`
```suggestion
        if PY314:
            kwargs.setdefault("suggest_on_error", True)
```

I think you mixed this patch with some unrelated changes in `makemessages`, please remove them.

Revert unnecessary blank line.

This should be moved to release notes for Django 6.1 (`6.1.txt`).

Use `skipUnless` decorator instead.

Vacuous comment, remove
```suggestion
```

Let's link to the argparse documentation, where the feature is described with an example. Also, your example was incorrect, as noted below in the tests—this feature only offers suggestions for mistyped subparser names and argument choices.

```suggestion
* Management commands now set :class:`~argparse.ArgumentParser`\'s
  ``suggest_on_error`` argument to ``True`` by [NAME_REDACTED] 3.14+, enabling
  suggestions for mistyped subparser names and argument choices.
```

I just spotted that the default has flipped to `True` on Python 3.15: https://github.com/[USER_REDACTED]n/cpython/commit/d2f3cfd38457204a2c47162de6f89ee3dd22fa99

Therefore, let's change the check to:
```suggestion
        if PY314 and not PY315 and called_from_command_line:
            # [NAME_REDACTED] 3.15 default early.
```

This way, when we drop Python 3.14 support, it should be clear that we can remove this block.

These tests do not need a dedicated test case and can be placed at the end of `CommandTests`

Some fixes for your tests:

1. Use `unittest.skipUnless` on all tests.
2. Use `not PY315` per the above comments about the default chanigng on Python 3.15.
3. Drop the `# ADD THIS LINE` comments, which look like they came from someone's review?
4. Drop the behaviour tests - they are not valuable, as they only test argparse's behaviour, which we can depend on.

```suggestion
    [USER_REDACTED].skipUnless(PY314 and not PY315, "[NAME_REDACTED] 3.14")
    def test_suggest_on_error_defaults_true(self):
        """
        CommandParser sets suggest_on_error=True on Python 3.14+.
        """
        command = BaseCommand()
        command._called_from_command_line = True
        parser = command.create_parser("prog_name", "subcommand")

        self.assertTrue(parser.suggest_on_error)

    [USER_REDACTED].skipUnless(PY314 and not PY315, "[NAME_REDACTED] 3.14+")
    def test_suggest_on_error_custom(self):
        """
        Explicit suggest_on_error=False is respected.
        """
        command = BaseCommand()
        command._called_from_command_line = True
        parser = command.create_parser(
            "prog_name", "subcommand", suggest_on_error=False
        )
        self.assertFalse(parser.suggest_on_error)
```

This test was not testing the correct output, or expecting the right correction. `suggest_on_error` does not affect mistyped options. It affects mistyped option *choices* and subparser names.

The way I'm testing it manually is:

```
$ python -m django check --fail-level EROR
...
__main__.py check: error: argument --fail-level: invalid choice: 'EROR', maybe you meant 'ERROR'? (choose from CRITICAL, ERROR, WARNING, INFO, DEBUG)
```

Note the "maybe you meant 'ERROR'?"

I don't think argparse *has* any feature to suggest for mistyped options at current. Did you see this assertion pass on any environment?

You're right [USER_REDACTED],  I apologize for the confusion. I did not actually test this on Python 3.14+. I was working on Python 3.12 and wrote tests based on the ticket description, which turned out to be incorrect about what `suggest_on_error` does.

Thank you for the clarification that it only affects:
- Mistyped argument **choices** (like `--fail-level EROR` → `ERROR`)
- Mistyped **subparser names** (like `python manage.py chek` → `check`)

And NOT mistyped option flags (like `--verbositty`).

I've removed the incorrect behavioral test. The remaining two tests just verify that the setting is applied correctly, which is appropriate since we're relying on argparse's implementation.

The ticket description should probably be updated to reflect the actual behavior of this feature.

Curious why you decided to add `and called_from_command_line` here, couldn't we opt in everywhere?

Hi [USER_REDACTED], I added the `called_from_command_line` check because I hit a pickling error in CI on Python 3.14:
```
PicklingError("Can't pickle local object <function ArgumentParser.__init__.<locals>.identity>")
```

This broke the parallel test runner. I thought limiting it to command-line usage would avoid the issue.

I see the issues persist after removing `called_from_command_line`.

I would really appreciate your guidance on this one.

I don't see the error, but I'm happy to look into it if you have steps to reproduce. Sometimes the parallel test runner spews irrelevant information underneath advice like:
```
In order to see the traceback, you should install tblib:

    python -m pip install tblib
```

Can you update the failing test? (test_invalid_choice_db_option)

Here's the full traceback from the [NAME_REDACTED] 3.14 CI run:
```
multiprocessing.pool.MaybeEncodingError: Error sending result: '<multiprocessing.pool.ExceptionWithTraceback object at 0x000002587F365940>'.
Reason: 'PicklingError("Can't pickle local object <function ArgumentParser.__init__.<locals>.identity at 0x000002587F4075E0>")'
```

This is happening in Django's parallel test runner when it tries to pickle test objects that contain `ArgumentParser` instances with `suggest_on_error=True`.

The error occurs consistently on Python 3.14 when `suggest_on_error=True` is enabled unconditionally. That's why I limited it to `called_from_command_line`. It still provides the feature to end users while avoiding the multiprocessing issue in tests.

Is there a better approach to handle this?

I'm not convinced that's relevant yet. I think we should start with fixing the test failure in `test_invalid_choice_db_option`.

Hi [USER_REDACTED], could you help me understand which specific test failure I should focus on? The CI logs show pickling errors, but I'm not seeing a clear test failure message.

Cause when I enable `suggest_on_error` everywhere, i get
<img width="758" height="871" alt="Screenshot from 2025-11-14 11-54-28" src="https://github.com/[USER_REDACTED]nts/assets/46d4ae55-b7d1-472d-a915-0ff5db338e70" />     But when i limit to when `called_from_command_line=True` when user runs command in the command line, the tests passes.

It's possible you haven't scrolled up far enough, I can't tell.

Here's the error, it's just an update needed for the expected string (based on the python version):

```
Traceback (most recent call last):
  File "/Users/jwalls/django/tests/admin_scripts/tests.py", line 2490, in test_invalid_choice_db_option
    with self.assertRaisesRegex(CommandError, expected_error):
         ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: "Error: argument --database: invalid choice: 'deflaut' \(choose from '?default'?, '?other'?\)" does not match "Error: argument --database: invalid choice: 'deflaut', maybe you meant 'default'? (choose from default, other)"

----------------------------------------------------------------------
```

[USER_REDACTED] Thank you for pinpointing the exact issue!

The test `test_invalid_choice_db_option` expects the error message format without suggestions, but on Python 3.14 with `suggest_on_error=True`, argparse now adds `", maybe you meant 'default'?"` to the message.

I've updated the test to use a conditional regex pattern based on Python versions:
- **Python 3.14**: Expects `"invalid choice: 'deflaut', maybe you meant 'default'? (choose from default, other)"`
- **Other versions**: Expects `"invalid choice: 'deflaut' (choose from 'default', 'other')"`

I added `PY314` and `PY315` imports to `tests/admin_scripts/tests.py`, updated `expected_error` to be conditional based on Python version, and adjusted the regex to match the new format on Python 3.14.

Does this approach look good, or would you prefer a different way to handle version differences?

We're expecting this change in all versions after 3.14. 3.15 alpha 2 comes out tomorrow and should have this change. So please go ahead and update this.
```suggestion
        if PY314:
```

You can chop this comment.

And this one.

Thank you [USER_REDACTED] 🙏

I’ve updated the condition to use only if `PY314:` as suggested, and removed the outdated comments. Please let me know if you'd like me to make any other adjustments.

Hey [USER_REDACTED]. My idea here was to keep the `and not PY315` but just lose the called from command line bit. The idea being that when we eventually drop python 3.14, we will know this code is _only_ needed on 3.14, and we can just remove the code instead of deindenting it at the time. Does that make sense?

That means adding `and not PY315` here again as well (and below)

Can you tell me the reason this line is needed?

Hey [USER_REDACTED], I see the confusion now! I misunderstood your earlier comment about changing to `if PY314:`. I thought you meant it should apply everywhere.

**In `base.py`:**
```python
if PY314 and not PY315:
    kwargs.setdefault("suggest_on_error", True)
```

This way, when Django drops Python 3.14 support, we know to remove this entire block (since 3.15+ has it as default).

**Regarding `command._called_from_command_line = True`:**

I had added the `called_from_command_line` check in `base.py` because I was getting an `error message format`  from the `test_invalid_choice_db_option`. After fixing the `admin_scripts` test expectations, those errors were resolved. I'll now remove the `command._called_from_command_line = True` lines from the tests.

Let me update this now. Thanks for your patience in clarifying!