Repository: django/django
Issue category: Bug Report

Issue:
ErrorList.copy() reverts to default renderer
Description
	 
When an ErrorList is copied, it loses track of any custom renderer, reverting to the default one. Practically, this means custom styles are not applied to non-field errors when rendering a whole form.
For example, I wrote a custom renderer that swaps some templates like so:
from django import forms
from django.forms.renderers import TemplatesSetting
from django.template.exceptions import TemplateDoesNotExist
class CustomRenderer(TemplatesSetting):
	def get_template(self, template_name):
		if template_name.startswith("django/forms/"):
			# Load our custom version from "custom/forms/" if it exists
			our_template = f"custom/{template_name.removeprefix('django/')}"
			try:
				return super().get_template(our_template)
			except TemplateDoesNotExist:
				pass
		return super().get_template(template_name)
class MyForm(forms.Form):
	default_renderer = CustomRenderer()
The custom error list template uses some CSS utility classes from Tailwind, like text-red-600:
{% if errors %}<ul class="text-red-600">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
Creating a form with a non-field error and rendering those errors uses the custom template:
In [1]: from example.forms import MyForm
 ...:
 ...: form = MyForm({})
 ...: form.full_clean()
 ...: form.add_error(None, "Test error")
In [2]: form.non_field_errors().render()
Out[2]: '<ul class="text-red-600"><li>Test error</li></ul>'
But rendering the whole form reverts to the default template, from the default renderer:
In [3]: form.render()
Out[3]: '<ul class="errorlist nonfield"><li>Test error</li></ul>\n\n <div></div>'
This occurs because the ErrorList is copied in Form.get_context():
​https://github.com/django/django/blob/1860a1afc9ac20750f932e8e0a94b32d096f2848/django/forms/forms.py#L225
The fix would be to also copy over renderer in ErrorList.copy():
​https://github.com/django/django/blob/1860a1afc9ac20750f932e8e0a94b32d096f2848/django/forms/utils.py#L165
I think this has probably been an issue ever since a custom renderer became possible in #31026.


Hints:
Thank you!, 1733723463

Patch:
diff --git a/django/forms/utils.py b/django/forms/utils.py
index d24711d1a0b5..27eabe57dc58 100644
--- a/django/forms/utils.py
+++ b/django/forms/utils.py
@@ -163,6 +163,7 @@ def as_data(self):
     def copy(self):
         copy = super().copy()
         copy.error_class = self.error_class
+        copy.renderer = self.renderer
         return copy
 
     def get_json_data(self, escape_html=False):


Test patch:
diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py
index cd909628cb03..f93d9b41157f 100644
--- a/tests/forms_tests/tests/test_forms.py
+++ b/tests/forms_tests/tests/test_forms.py
@@ -4849,6 +4849,12 @@ class CustomForm(Form):
         form = CustomForm(renderer=custom)
         self.assertEqual(form.renderer, custom)
 
+    def test_get_context_errors(self):
+        custom = CustomRenderer()
+        form = Form(renderer=custom)
+        context = form.get_context()
+        self.assertEqual(context["errors"].renderer, custom)
+
 
 class TemplateTests(SimpleTestCase):
     def test_iterate_radios(self):
diff --git a/tests/forms_tests/tests/test_utils.py b/tests/forms_tests/tests/test_utils.py
index f9a5d4c82a6a..a50f86c934b3 100644
--- a/tests/forms_tests/tests/test_utils.py
+++ b/tests/forms_tests/tests/test_utils.py
@@ -2,6 +2,7 @@
 import json
 
 from django.core.exceptions import ValidationError
+from django.forms.renderers import DjangoTemplates
 from django.forms.utils import (
     ErrorDict,
     ErrorList,
@@ -161,6 +162,35 @@ def __str__(self):
             '<a href="http://www.example.com/">example</a></li></ul>',
         )
 
+    def test_error_list_copy(self):
+        e = ErrorList(
+            [
+                ValidationError(
+                    message="message %(i)s",
+                    params={"i": 1},
+                ),
+                ValidationError(
+                    message="message %(i)s",
+                    params={"i": 2},
+                ),
+            ]
+        )
+
+        e_copy = copy.copy(e)
+        self.assertEqual(e, e_copy)
+        self.assertEqual(e.as_data(), e_copy.as_data())
+
+    def test_error_list_copy_attributes(self):
+        class CustomRenderer(DjangoTemplates):
+            pass
+
+        renderer = CustomRenderer()
+        e = ErrorList(error_class="woopsies", renderer=renderer)
+
+        e_copy = e.copy()
+        self.assertEqual(e.error_class, e_copy.error_class)
+        self.assertEqual(e.renderer, e_copy.renderer)
+
     def test_error_dict_copy(self):
         e = ErrorDict()
         e["__all__"] = ErrorList(
@@ -183,6 +213,16 @@ def test_error_dict_copy(self):
         e_deepcopy = copy.deepcopy(e)
         self.assertEqual(e, e_deepcopy)
 
+    def test_error_dict_copy_attributes(self):
+        class CustomRenderer(DjangoTemplates):
+            pass
+
+        renderer = CustomRenderer()
+        e = ErrorDict(renderer=renderer)
+
+        e_copy = copy.copy(e)
+        self.assertEqual(e.renderer, e_copy.renderer)
+
     def test_error_dict_html_safe(self):
         e = ErrorDict()
         e["username"] = "Invalid username."