Browse Source

Fixed #31026 -- Switched form rendering to template engine.

Thanks Carlton Gibson, Keryn Knight, Mariusz Felisiak, and Nick Pope
for reviews.

Co-authored-by: Johannes Hoppe <info@johanneshoppe.com>
David Smith 3 years ago
parent
commit
456466d932
56 changed files with 1048 additions and 330 deletions
  1. 11 9
      django/forms/boundfield.py
  2. 55 40
      django/forms/forms.py
  3. 29 36
      django/forms/formsets.py
  4. 1 0
      django/forms/jinja2/django/forms/attrs.html
  5. 1 0
      django/forms/jinja2/django/forms/default.html
  6. 1 0
      django/forms/jinja2/django/forms/errors/dict/default.html
  7. 3 0
      django/forms/jinja2/django/forms/errors/dict/text.txt
  8. 1 0
      django/forms/jinja2/django/forms/errors/dict/ul.html
  9. 1 0
      django/forms/jinja2/django/forms/errors/list/default.html
  10. 2 0
      django/forms/jinja2/django/forms/errors/list/text.txt
  11. 1 0
      django/forms/jinja2/django/forms/errors/list/ul.html
  12. 1 0
      django/forms/jinja2/django/forms/formsets/default.html
  13. 1 0
      django/forms/jinja2/django/forms/formsets/p.html
  14. 1 0
      django/forms/jinja2/django/forms/formsets/table.html
  15. 1 0
      django/forms/jinja2/django/forms/formsets/ul.html
  16. 1 0
      django/forms/jinja2/django/forms/label.html
  17. 20 0
      django/forms/jinja2/django/forms/p.html
  18. 29 0
      django/forms/jinja2/django/forms/table.html
  19. 24 0
      django/forms/jinja2/django/forms/ul.html
  20. 13 5
      django/forms/models.py
  21. 1 0
      django/forms/templates/django/forms/attrs.html
  22. 1 0
      django/forms/templates/django/forms/default.html
  23. 1 0
      django/forms/templates/django/forms/errors/dict/default.html
  24. 3 0
      django/forms/templates/django/forms/errors/dict/text.txt
  25. 1 0
      django/forms/templates/django/forms/errors/dict/ul.html
  26. 1 0
      django/forms/templates/django/forms/errors/list/default.html
  27. 2 0
      django/forms/templates/django/forms/errors/list/text.txt
  28. 1 0
      django/forms/templates/django/forms/errors/list/ul.html
  29. 1 0
      django/forms/templates/django/forms/formsets/default.html
  30. 1 0
      django/forms/templates/django/forms/formsets/p.html
  31. 1 0
      django/forms/templates/django/forms/formsets/table.html
  32. 1 0
      django/forms/templates/django/forms/formsets/ul.html
  33. 1 0
      django/forms/templates/django/forms/label.html
  34. 20 0
      django/forms/templates/django/forms/p.html
  35. 29 0
      django/forms/templates/django/forms/table.html
  36. 24 0
      django/forms/templates/django/forms/ul.html
  37. 71 45
      django/forms/utils.py
  38. 5 0
      docs/internals/deprecation.txt
  39. 173 33
      docs/ref/forms/api.txt
  40. 5 1
      docs/ref/forms/formsets.txt
  41. 13 5
      docs/ref/forms/models.txt
  42. 54 5
      docs/ref/forms/renderers.txt
  43. 3 2
      docs/ref/settings.txt
  44. 17 0
      docs/releases/4.0.txt
  45. 94 1
      docs/topics/forms/formsets.txt
  46. 14 10
      docs/topics/forms/index.txt
  47. 1 1
      tests/admin_views/tests.py
  48. 1 0
      tests/forms_tests/templates/forms_tests/error.html
  49. 6 0
      tests/forms_tests/templates/forms_tests/form_snippet.html
  50. 30 0
      tests/forms_tests/tests/__init__.py
  51. 183 0
      tests/forms_tests/tests/test_deprecation_forms.py
  52. 39 135
      tests/forms_tests/tests/test_forms.py
  53. 30 2
      tests/forms_tests/tests/test_formsets.py
  54. 2 0
      tests/forms_tests/tests/test_i18n.py
  55. 2 0
      tests/forms_tests/tests/tests.py
  56. 19 0
      tests/model_formsets/tests.py

+ 11 - 9
django/forms/boundfield.py

@@ -1,11 +1,10 @@
 import re
 
 from django.core.exceptions import ValidationError
-from django.forms.utils import flatatt, pretty_name
+from django.forms.utils import pretty_name
 from django.forms.widgets import MultiWidget, Textarea, TextInput
 from django.utils.functional import cached_property
-from django.utils.html import conditional_escape, format_html, html_safe
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html, html_safe
 from django.utils.translation import gettext_lazy as _
 
 __all__ = ('BoundField',)
@@ -75,7 +74,7 @@ class BoundField:
         """
         Return an ErrorList (empty if there are no errors) for this field.
         """
-        return self.form.errors.get(self.name, self.form.error_class())
+        return self.form.errors.get(self.name, self.form.error_class(renderer=self.form.renderer))
 
     def as_widget(self, widget=None, attrs=None, only_initial=False):
         """
@@ -177,11 +176,14 @@ class BoundField:
                     attrs['class'] += ' ' + self.form.required_css_class
                 else:
                     attrs['class'] = self.form.required_css_class
-            attrs = flatatt(attrs) if attrs else ''
-            contents = format_html('<label{}>{}</label>', attrs, contents)
-        else:
-            contents = conditional_escape(contents)
-        return mark_safe(contents)
+        context = {
+            'form': self.form,
+            'field': self,
+            'label': contents,
+            'attrs': attrs,
+            'use_tag': bool(id_),
+        }
+        return self.form.render(self.form.template_name_label, context)
 
     def css_classes(self, extra_classes=None):
         """

+ 55 - 40
django/forms/forms.py

@@ -4,15 +4,17 @@ Form classes
 
 import copy
 import datetime
+import warnings
 
 from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
 from django.forms.fields import Field, FileField
-from django.forms.utils import ErrorDict, ErrorList
+from django.forms.utils import ErrorDict, ErrorList, RenderableFormMixin
 from django.forms.widgets import Media, MediaDefiningClass
 from django.utils.datastructures import MultiValueDict
+from django.utils.deprecation import RemovedInDjango50Warning
 from django.utils.functional import cached_property
-from django.utils.html import conditional_escape, html_safe
-from django.utils.safestring import mark_safe
+from django.utils.html import conditional_escape
+from django.utils.safestring import SafeString, mark_safe
 from django.utils.translation import gettext as _
 
 from .renderers import get_default_renderer
@@ -49,8 +51,7 @@ class DeclarativeFieldsMetaclass(MediaDefiningClass):
         return new_class
 
 
-@html_safe
-class BaseForm:
+class BaseForm(RenderableFormMixin):
     """
     The main implementation of all the Form logic. Note that this class is
     different than Form. See the comments by the Form class for more info. Any
@@ -62,6 +63,12 @@ class BaseForm:
     prefix = None
     use_required_attribute = True
 
+    template_name = 'django/forms/default.html'
+    template_name_p = 'django/forms/p.html'
+    template_name_table = 'django/forms/table.html'
+    template_name_ul = 'django/forms/ul.html'
+    template_name_label = 'django/forms/label.html'
+
     def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
                  initial=None, error_class=ErrorList, label_suffix=None,
                  empty_permitted=False, field_order=None, use_required_attribute=None, renderer=None):
@@ -129,9 +136,6 @@ class BaseForm:
         fields.update(self.fields)  # add remaining fields in original order
         self.fields = fields
 
-    def __str__(self):
-        return self.as_table()
-
     def __repr__(self):
         if self._errors is None:
             is_valid = "Unknown"
@@ -206,6 +210,12 @@ class BaseForm:
 
     def _html_output(self, normal_row, error_row, row_ender, help_text_html, errors_on_separate_row):
         "Output HTML. Used by as_table(), as_ul(), as_p()."
+        warnings.warn(
+            'django.forms.BaseForm._html_output() is deprecated. '
+            'Please use .render() and .get_context() instead.',
+            RemovedInDjango50Warning,
+            stacklevel=2,
+        )
         # Errors that should be displayed above all fields.
         top_errors = self.non_field_errors().copy()
         output, hidden_fields = [], []
@@ -282,35 +292,37 @@ class BaseForm:
                 output.append(str_hidden)
         return mark_safe('\n'.join(output))
 
-    def as_table(self):
-        "Return this form rendered as HTML <tr>s -- excluding the <table></table>."
-        return self._html_output(
-            normal_row='<tr%(html_class_attr)s><th>%(label)s</th><td>%(errors)s%(field)s%(help_text)s</td></tr>',
-            error_row='<tr><td colspan="2">%s</td></tr>',
-            row_ender='</td></tr>',
-            help_text_html='<br><span class="helptext">%s</span>',
-            errors_on_separate_row=False,
-        )
-
-    def as_ul(self):
-        "Return this form rendered as HTML <li>s -- excluding the <ul></ul>."
-        return self._html_output(
-            normal_row='<li%(html_class_attr)s>%(errors)s%(label)s %(field)s%(help_text)s</li>',
-            error_row='<li>%s</li>',
-            row_ender='</li>',
-            help_text_html=' <span class="helptext">%s</span>',
-            errors_on_separate_row=False,
-        )
-
-    def as_p(self):
-        "Return this form rendered as HTML <p>s."
-        return self._html_output(
-            normal_row='<p%(html_class_attr)s>%(label)s %(field)s%(help_text)s</p>',
-            error_row='%s',
-            row_ender='</p>',
-            help_text_html=' <span class="helptext">%s</span>',
-            errors_on_separate_row=True,
-        )
+    def get_context(self):
+        fields = []
+        hidden_fields = []
+        top_errors = self.non_field_errors().copy()
+        for name, bf in self._bound_items():
+            bf_errors = self.error_class(bf.errors, renderer=self.renderer)
+            if bf.is_hidden:
+                if bf_errors:
+                    top_errors += [
+                        _('(Hidden field %(name)s) %(error)s') % {'name': name, 'error': str(e)}
+                        for e in bf_errors
+                    ]
+                hidden_fields.append(bf)
+            else:
+                errors_str = str(bf_errors)
+                # RemovedInDjango50Warning.
+                if not isinstance(errors_str, SafeString):
+                    warnings.warn(
+                        f'Returning a plain string from '
+                        f'{self.error_class.__name__} is deprecated. Please '
+                        f'customize via the template system instead.',
+                        RemovedInDjango50Warning,
+                    )
+                    errors_str = mark_safe(errors_str)
+                fields.append((bf, errors_str))
+        return {
+            'form': self,
+            'fields': fields,
+            'hidden_fields': hidden_fields,
+            'errors': top_errors,
+        }
 
     def non_field_errors(self):
         """
@@ -318,7 +330,10 @@ class BaseForm:
         field -- i.e., from Form.clean(). Return an empty ErrorList if there
         are none.
         """
-        return self.errors.get(NON_FIELD_ERRORS, self.error_class(error_class='nonfield'))
+        return self.errors.get(
+            NON_FIELD_ERRORS,
+            self.error_class(error_class='nonfield', renderer=self.renderer),
+        )
 
     def add_error(self, field, error):
         """
@@ -360,9 +375,9 @@ class BaseForm:
                     raise ValueError(
                         "'%s' has no field named '%s'." % (self.__class__.__name__, field))
                 if field == NON_FIELD_ERRORS:
-                    self._errors[field] = self.error_class(error_class='nonfield')
+                    self._errors[field] = self.error_class(error_class='nonfield', renderer=self.renderer)
                 else:
-                    self._errors[field] = self.error_class()
+                    self._errors[field] = self.error_class(renderer=self.renderer)
             self._errors[field].extend(error_list)
             if field in self.cleaned_data:
                 del self.cleaned_data[field]

+ 29 - 36
django/forms/formsets.py

@@ -1,11 +1,10 @@
 from django.core.exceptions import ValidationError
 from django.forms import Form
 from django.forms.fields import BooleanField, IntegerField
-from django.forms.utils import ErrorList
+from django.forms.renderers import get_default_renderer
+from django.forms.utils import ErrorList, RenderableFormMixin
 from django.forms.widgets import CheckboxInput, HiddenInput, NumberInput
 from django.utils.functional import cached_property
-from django.utils.html import html_safe
-from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _, ngettext
 
 __all__ = ('BaseFormSet', 'formset_factory', 'all_valid')
@@ -50,8 +49,7 @@ class ManagementForm(Form):
         return cleaned_data
 
 
-@html_safe
-class BaseFormSet:
+class BaseFormSet(RenderableFormMixin):
     """
     A collection of instances of the same Form class.
     """
@@ -63,6 +61,10 @@ class BaseFormSet:
             '%(field_names)s. You may need to file a bug report if the issue persists.'
         ),
     }
+    template_name = 'django/forms/formsets/default.html'
+    template_name_p = 'django/forms/formsets/p.html'
+    template_name_table = 'django/forms/formsets/table.html'
+    template_name_ul = 'django/forms/formsets/ul.html'
 
     def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
                  initial=None, error_class=ErrorList, form_kwargs=None,
@@ -85,9 +87,6 @@ class BaseFormSet:
             messages.update(error_messages)
         self.error_messages = messages
 
-    def __str__(self):
-        return self.as_table()
-
     def __iter__(self):
         """Yield the forms in the order they should be rendered."""
         return iter(self.forms)
@@ -110,15 +109,20 @@ class BaseFormSet:
     def management_form(self):
         """Return the ManagementForm instance for this FormSet."""
         if self.is_bound:
-            form = ManagementForm(self.data, auto_id=self.auto_id, prefix=self.prefix)
+            form = ManagementForm(self.data, auto_id=self.auto_id, prefix=self.prefix, renderer=self.renderer)
             form.full_clean()
         else:
-            form = ManagementForm(auto_id=self.auto_id, prefix=self.prefix, initial={
-                TOTAL_FORM_COUNT: self.total_form_count(),
-                INITIAL_FORM_COUNT: self.initial_form_count(),
-                MIN_NUM_FORM_COUNT: self.min_num,
-                MAX_NUM_FORM_COUNT: self.max_num
-            })
+            form = ManagementForm(
+                auto_id=self.auto_id,
+                prefix=self.prefix,
+                initial={
+                    TOTAL_FORM_COUNT: self.total_form_count(),
+                    INITIAL_FORM_COUNT: self.initial_form_count(),
+                    MIN_NUM_FORM_COUNT: self.min_num,
+                    MAX_NUM_FORM_COUNT: self.max_num,
+                },
+                renderer=self.renderer,
+            )
         return form
 
     def total_form_count(self):
@@ -177,6 +181,7 @@ class BaseFormSet:
             # incorrect validation for extra, optional, and deleted
             # forms in the formset.
             'use_required_attribute': False,
+            'renderer': self.renderer,
         }
         if self.is_bound:
             defaults['data'] = self.data
@@ -212,7 +217,8 @@ class BaseFormSet:
             prefix=self.add_prefix('__prefix__'),
             empty_permitted=True,
             use_required_attribute=False,
-            **self.get_form_kwargs(None)
+            **self.get_form_kwargs(None),
+            renderer=self.renderer,
         )
         self.add_fields(form, None)
         return form
@@ -338,7 +344,7 @@ class BaseFormSet:
         self._non_form_errors.
         """
         self._errors = []
-        self._non_form_errors = self.error_class(error_class='nonform')
+        self._non_form_errors = self.error_class(error_class='nonform', renderer=self.renderer)
         empty_forms_count = 0
 
         if not self.is_bound:  # Stop further processing.
@@ -387,7 +393,8 @@ class BaseFormSet:
         except ValidationError as e:
             self._non_form_errors = self.error_class(
                 e.error_list,
-                error_class='nonform'
+                error_class='nonform',
+                renderer=self.renderer,
             )
 
     def clean(self):
@@ -450,29 +457,14 @@ class BaseFormSet:
         else:
             return self.empty_form.media
 
-    def as_table(self):
-        "Return this formset rendered as HTML <tr>s -- excluding the <table></table>."
-        # XXX: there is no semantic division between forms here, there
-        # probably should be. It might make sense to render each form as a
-        # table row with each field as a td.
-        forms = ' '.join(form.as_table() for form in self)
-        return mark_safe(str(self.management_form) + '\n' + forms)
-
-    def as_p(self):
-        "Return this formset rendered as HTML <p>s."
-        forms = ' '.join(form.as_p() for form in self)
-        return mark_safe(str(self.management_form) + '\n' + forms)
-
-    def as_ul(self):
-        "Return this formset rendered as HTML <li>s."
-        forms = ' '.join(form.as_ul() for form in self)
-        return mark_safe(str(self.management_form) + '\n' + forms)
+    def get_context(self):
+        return {'formset': self}
 
 
 def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False,
                     can_delete=False, max_num=None, validate_max=False,
                     min_num=None, validate_min=False, absolute_max=None,
-                    can_delete_extra=True):
+                    can_delete_extra=True, renderer=None):
     """Return a FormSet for the given form class."""
     if min_num is None:
         min_num = DEFAULT_MIN_NUM
@@ -498,6 +490,7 @@ def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False,
         'absolute_max': absolute_max,
         'validate_min': validate_min,
         'validate_max': validate_max,
+        'renderer': renderer or get_default_renderer(),
     }
     return type(form.__name__ + 'FormSet', (formset,), attrs)
 

+ 1 - 0
django/forms/jinja2/django/forms/attrs.html

@@ -0,0 +1 @@
+{% for name, value in attrs.items() %}{% if value is not sameas False %} {{ name }}{% if value is not sameas True %}="{{ value }}"{% endif %}{% endif %}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/default.html

@@ -0,0 +1 @@
+{% include "django/forms/table.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/errors/dict/default.html

@@ -0,0 +1 @@
+{% include "django/forms/errors/dict/ul.html" %}

+ 3 - 0
django/forms/jinja2/django/forms/errors/dict/text.txt

@@ -0,0 +1,3 @@
+{% for field, errors in errors %}* {{ field }}
+{% for error in errors %}  * {{ error }}
+{% endfor %}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/errors/dict/ul.html

@@ -0,0 +1 @@
+{% if errors %}<ul class="{{ error_class }}">{% for field, error in errors %}<li>{{ field }}{{ error }}</li>{% endfor %}</ul>{% endif %}

+ 1 - 0
django/forms/jinja2/django/forms/errors/list/default.html

@@ -0,0 +1 @@
+{% include "django/forms/errors/list/ul.html" %}

+ 2 - 0
django/forms/jinja2/django/forms/errors/list/text.txt

@@ -0,0 +1,2 @@
+{% for error in errors %}* {{ error }}
+{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/errors/list/ul.html

@@ -0,0 +1 @@
+{% if errors %}<ul class="{{ error_class }}">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}

+ 1 - 0
django/forms/jinja2/django/forms/formsets/default.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form }}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/formsets/p.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form.as_p() }}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/formsets/table.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form.as_table() }}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/formsets/ul.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form.as_ul() }}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/label.html

@@ -0,0 +1 @@
+{% if use_tag %}<label{% if attrs %}{% include 'django/forms/attrs.html' %}{% endif %}>{{ label }}</label>{% else %}{{ label }}{% endif %}

+ 20 - 0
django/forms/jinja2/django/forms/p.html

@@ -0,0 +1,20 @@
+{{ errors }}
+{% if errors and not fields %}
+  <p>{% for field in hidden_fields %}{{ field }}{% endfor %}</p>
+{% endif %}
+{% for field, errors in fields %}
+  {{ errors }}
+  <p{% set classes = field.css_classes() %}{% if classes %} class="{{ classes }}"{% endif %}>
+    {% if field.label %}{{ field.label_tag() }}{% endif %}
+    {{ field }}
+    {% if field.help_text %}
+      <span class="helptext">{{ field.help_text }}</span>
+    {% endif %}
+    {% if loop.last %}
+      {% for field in hidden_fields %}{{ field }}{% endfor %}
+    {% endif %}
+  </p>
+{% endfor %}
+{% if not fields and not errors %}
+  {% for field in hidden_fields %}{{ field }}{% endfor %}
+{% endif %}

+ 29 - 0
django/forms/jinja2/django/forms/table.html

@@ -0,0 +1,29 @@
+{% if errors %}
+  <tr>
+    <td colspan="2">
+      {{ errors }}
+      {% if not fields %}
+        {% for field in hidden_fields %}{{ field }}{% endfor %}
+      {% endif %}
+    </td>
+  </tr>
+{% endif %}
+{% for field, errors in fields %}
+  <tr{% set classes = field.css_classes() %}{% if classes %} class="{{ classes }}"{% endif %}>
+    <th>{% if field.label %}{{ field.label_tag() }}{% endif %}</th>
+    <td>
+      {{ errors }}
+      {{ field }}
+      {% if field.help_text %}
+        <br>
+        <span class="helptext">{{ field.help_text }}</span>
+      {% endif %}
+      {% if loop.last %}
+        {% for field in hidden_fields %}{{ field }}{% endfor %}
+      {% endif %}
+    </td>
+  </tr>
+{% endfor %}
+{% if not fields and not errors %}
+  {% for field in hidden_fields %}{{ field }}{% endfor %}
+{% endif %}

+ 24 - 0
django/forms/jinja2/django/forms/ul.html

@@ -0,0 +1,24 @@
+{% if errors %}
+  <li>
+    {{ errors }}
+  {% if not fields %}
+    {% for field in hidden_fields %}{{ field }}{% endfor %}
+  {% endif %}
+  </li>
+{% endif %}
+{% for field, errors in fields %}
+  <li{% set classes = field.css_classes() %}{% if classes %} class="{{ classes }}"{% endif %}>
+    {{ errors }}
+    {% if field.label %}{{ field.label_tag() }}{% endif %}
+    {{ field }}
+    {% if field.help_text %}
+      <span class="helptext">{{ field.help_text }}</span>
+    {% endif %}
+    {% if loop.last %}
+      {% for field in hidden_fields %}{{ field }}{% endfor %}
+    {% endif %}
+  </li>
+{% endfor %}
+{% if not fields and not errors %}
+  {% for field in hidden_fields %}{{ field }}{% endfor %}
+{% endif %}

+ 13 - 5
django/forms/models.py

@@ -718,7 +718,10 @@ class BaseModelFormSet(BaseFormSet):
                         # poke error messages into the right places and mark
                         # the form as invalid
                         errors.append(self.get_unique_error_message(unique_check))
-                        form._errors[NON_FIELD_ERRORS] = self.error_class([self.get_form_error()])
+                        form._errors[NON_FIELD_ERRORS] = self.error_class(
+                            [self.get_form_error()],
+                            renderer=self.renderer,
+                        )
                         # remove the data from the cleaned_data dict since it was invalid
                         for field in unique_check:
                             if field in form.cleaned_data:
@@ -747,7 +750,10 @@ class BaseModelFormSet(BaseFormSet):
                         # poke error messages into the right places and mark
                         # the form as invalid
                         errors.append(self.get_date_error_message(date_check))
-                        form._errors[NON_FIELD_ERRORS] = self.error_class([self.get_form_error()])
+                        form._errors[NON_FIELD_ERRORS] = self.error_class(
+                            [self.get_form_error()],
+                            renderer=self.renderer,
+                        )
                         # remove the data from the cleaned_data dict since it was invalid
                         del form.cleaned_data[field]
                     # mark the data as seen
@@ -869,7 +875,7 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None,
                          widgets=None, validate_max=False, localized_fields=None,
                          labels=None, help_texts=None, error_messages=None,
                          min_num=None, validate_min=False, field_classes=None,
-                         absolute_max=None, can_delete_extra=True):
+                         absolute_max=None, can_delete_extra=True, renderer=None):
     """Return a FormSet class for the given Django model class."""
     meta = getattr(form, 'Meta', None)
     if (getattr(meta, 'fields', fields) is None and
@@ -887,7 +893,8 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None,
     FormSet = formset_factory(form, formset, extra=extra, min_num=min_num, max_num=max_num,
                               can_order=can_order, can_delete=can_delete,
                               validate_min=validate_min, validate_max=validate_max,
-                              absolute_max=absolute_max, can_delete_extra=can_delete_extra)
+                              absolute_max=absolute_max, can_delete_extra=can_delete_extra,
+                              renderer=renderer)
     FormSet.model = model
     return FormSet
 
@@ -1069,7 +1076,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm,
                           widgets=None, validate_max=False, localized_fields=None,
                           labels=None, help_texts=None, error_messages=None,
                           min_num=None, validate_min=False, field_classes=None,
-                          absolute_max=None, can_delete_extra=True):
+                          absolute_max=None, can_delete_extra=True, renderer=None):
     """
     Return an ``InlineFormSet`` for the given kwargs.
 
@@ -1101,6 +1108,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm,
         'field_classes': field_classes,
         'absolute_max': absolute_max,
         'can_delete_extra': can_delete_extra,
+        'renderer': renderer,
     }
     FormSet = modelformset_factory(model, **kwargs)
     FormSet.fk = fk

+ 1 - 0
django/forms/templates/django/forms/attrs.html

@@ -0,0 +1 @@
+{% for name, value in attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/default.html

@@ -0,0 +1 @@
+{% include "django/forms/table.html" %}

+ 1 - 0
django/forms/templates/django/forms/errors/dict/default.html

@@ -0,0 +1 @@
+{% include "django/forms/errors/dict/ul.html" %}

+ 3 - 0
django/forms/templates/django/forms/errors/dict/text.txt

@@ -0,0 +1,3 @@
+{% for field, errors in errors %}* {{ field }}
+{% for error in errors %}  * {{ error }}
+{% endfor %}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/errors/dict/ul.html

@@ -0,0 +1 @@
+{% if errors %}<ul class="{{ error_class }}">{% for field, error in errors %}<li>{{ field }}{{ error }}</li>{% endfor %}</ul>{% endif %}

+ 1 - 0
django/forms/templates/django/forms/errors/list/default.html

@@ -0,0 +1 @@
+{% include "django/forms/errors/list/ul.html" %}

+ 2 - 0
django/forms/templates/django/forms/errors/list/text.txt

@@ -0,0 +1,2 @@
+{% for error in errors %}* {{ error }}
+{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/errors/list/ul.html

@@ -0,0 +1 @@
+{% if errors %}<ul class="{{ error_class }}">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}

+ 1 - 0
django/forms/templates/django/forms/formsets/default.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form }}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/formsets/p.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form.as_p }}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/formsets/table.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form.as_table }}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/formsets/ul.html

@@ -0,0 +1 @@
+{{ formset.management_form }}{% for form in formset %}{{ form.as_ul }}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/label.html

@@ -0,0 +1 @@
+{% if use_tag %}<label{% include 'django/forms/attrs.html' %}>{{ label }}</label>{% else %}{{ label }}{% endif %}

+ 20 - 0
django/forms/templates/django/forms/p.html

@@ -0,0 +1,20 @@
+{{ errors }}
+{% if errors and not fields %}
+  <p>{% for field in hidden_fields %}{{ field }}{% endfor %}</p>
+{% endif %}
+{% for field, errors in fields %}
+  {{ errors }}
+  <p{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
+    {% if field.label %}{{ field.label_tag }}{% endif %}
+    {{ field }}
+    {% if field.help_text %}
+      <span class="helptext">{{ field.help_text }}</span>
+    {% endif %}
+    {% if forloop.last %}
+      {% for field in hidden_fields %}{{ field }}{% endfor %}
+    {% endif %}
+  </p>
+{% endfor %}
+{% if not fields and not errors %}
+  {% for field in hidden_fields %}{{ field }}{% endfor %}
+{% endif %}

+ 29 - 0
django/forms/templates/django/forms/table.html

@@ -0,0 +1,29 @@
+{% if errors %}
+  <tr>
+    <td colspan="2">
+      {{ errors }}
+      {% if not fields %}
+        {% for field in hidden_fields %}{{ field }}{% endfor %}
+      {% endif %}
+    </td>
+  </tr>
+{% endif %}
+{% for field, errors in fields %}
+  <tr{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
+    <th>{% if field.label %}{{ field.label_tag }}{% endif %}</th>
+    <td>
+      {{ errors }}
+      {{ field }}
+      {% if field.help_text %}
+        <br>
+        <span class="helptext">{{ field.help_text }}</span>
+      {% endif %}
+      {% if forloop.last %}
+        {% for field in hidden_fields %}{{ field }}{% endfor %}
+      {% endif %}
+    </td>
+  </tr>
+{% endfor %}
+{% if not fields and not errors %}
+  {% for field in hidden_fields %}{{ field }}{% endfor %}
+{% endif %}

+ 24 - 0
django/forms/templates/django/forms/ul.html

@@ -0,0 +1,24 @@
+{% if errors %}
+  <li>
+    {{ errors }}
+  {% if not fields %}
+    {% for field in hidden_fields %}{{ field }}{% endfor %}
+  {% endif %}
+  </li>
+{% endif %}
+{% for field, errors in fields %}
+  <li{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
+    {{ errors }}
+    {% if field.label %}{{ field.label_tag }}{% endif %}
+    {{ field }}
+    {% if field.help_text %}
+      <span class="helptext">{{ field.help_text }}</span>
+    {% endif %}
+    {% if forloop.last %}
+      {% for field in hidden_fields %}{{ field }}{% endfor %}
+    {% endif %}
+  </li>
+{% endfor %}
+{% if not fields and not errors %}
+  {% for field in hidden_fields %}{{ field }}{% endfor %}
+{% endif %}

+ 71 - 45
django/forms/utils.py

@@ -1,10 +1,12 @@
 import json
-from collections import UserList
+from collections import UserDict, UserList
 
 from django.conf import settings
 from django.core.exceptions import ValidationError
+from django.forms.renderers import get_default_renderer
 from django.utils import timezone
-from django.utils.html import escape, format_html, format_html_join, html_safe
+from django.utils.html import escape, format_html_join
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 
@@ -41,53 +43,90 @@ def flatatt(attrs):
     )
 
 
-@html_safe
-class ErrorDict(dict):
+class RenderableMixin:
+    def get_context(self):
+        raise NotImplementedError(
+            'Subclasses of RenderableMixin must provide a get_context() method.'
+        )
+
+    def render(self, template_name=None, context=None, renderer=None):
+        return mark_safe((renderer or self.renderer).render(
+            template_name or self.template_name,
+            context or self.get_context(),
+        ))
+
+    __str__ = render
+    __html__ = render
+
+
+class RenderableFormMixin(RenderableMixin):
+    def as_p(self):
+        """Render as <p> elements."""
+        return self.render(self.template_name_p)
+
+    def as_table(self):
+        """Render as <tr> elements excluding the surrounding <table> tag."""
+        return self.render(self.template_name_table)
+
+    def as_ul(self):
+        """Render as <li> elements excluding the surrounding <ul> tag."""
+        return self.render(self.template_name_ul)
+
+
+class RenderableErrorMixin(RenderableMixin):
+    def as_json(self, escape_html=False):
+        return json.dumps(self.get_json_data(escape_html))
+
+    def as_text(self):
+        return self.render(self.template_name_text)
+
+    def as_ul(self):
+        return self.render(self.template_name_ul)
+
+
+class ErrorDict(UserDict, RenderableErrorMixin):
     """
     A collection of errors that knows how to display itself in various formats.
 
     The dictionary keys are the field names, and the values are the errors.
     """
+    template_name = 'django/forms/errors/dict/default.html'
+    template_name_text = 'django/forms/errors/dict/text.txt'
+    template_name_ul = 'django/forms/errors/dict/ul.html'
+
+    def __init__(self, data=None, renderer=None):
+        super().__init__(data)
+        self.renderer = renderer or get_default_renderer()
+
     def as_data(self):
         return {f: e.as_data() for f, e in self.items()}
 
     def get_json_data(self, escape_html=False):
         return {f: e.get_json_data(escape_html) for f, e in self.items()}
 
-    def as_json(self, escape_html=False):
-        return json.dumps(self.get_json_data(escape_html))
-
-    def as_ul(self):
-        if not self:
-            return ''
-        return format_html(
-            '<ul class="errorlist">{}</ul>',
-            format_html_join('', '<li>{}{}</li>', self.items())
-        )
-
-    def as_text(self):
-        output = []
-        for field, errors in self.items():
-            output.append('* %s' % field)
-            output.append('\n'.join('  * %s' % e for e in errors))
-        return '\n'.join(output)
+    def get_context(self):
+        return {
+            'errors': self.items(),
+            'error_class': 'errorlist',
+        }
 
-    def __str__(self):
-        return self.as_ul()
 
-
-@html_safe
-class ErrorList(UserList, list):
+class ErrorList(UserList, list, RenderableErrorMixin):
     """
     A collection of errors that knows how to display itself in various formats.
     """
-    def __init__(self, initlist=None, error_class=None):
+    template_name = 'django/forms/errors/list/default.html'
+    template_name_text = 'django/forms/errors/list/text.txt'
+    template_name_ul = 'django/forms/errors/list/ul.html'
+
+    def __init__(self, initlist=None, error_class=None, renderer=None):
         super().__init__(initlist)
 
         if error_class is None:
             self.error_class = 'errorlist'
         else:
             self.error_class = 'errorlist {}'.format(error_class)
+        self.renderer = renderer or get_default_renderer()
 
     def as_data(self):
         return ValidationError(self.data).error_list
@@ -107,24 +146,11 @@ class ErrorList(UserList, list):
             })
         return errors
 
-    def as_json(self, escape_html=False):
-        return json.dumps(self.get_json_data(escape_html))
-
-    def as_ul(self):
-        if not self.data:
-            return ''
-
-        return format_html(
-            '<ul class="{}">{}</ul>',
-            self.error_class,
-            format_html_join('', '<li>{}</li>', ((e,) for e in self))
-        )
-
-    def as_text(self):
-        return '\n'.join('* %s' % e for e in self)
-
-    def __str__(self):
-        return self.as_ul()
+    def get_context(self):
+        return {
+            'errors': self,
+            'error_class': self.error_class,
+        }
 
     def __repr__(self):
         return repr(list(self))

+ 5 - 0
docs/internals/deprecation.txt

@@ -57,6 +57,11 @@ details on these changes.
 * The ``django.contrib.gis.admin.GeoModelAdmin`` and ``OSMGeoAdmin`` classes
   will be removed.
 
+* The undocumented ``BaseForm._html_output()`` method will be removed.
+
+* The ability to return a ``str``, rather than a ``SafeString``, when rendering
+  an ``ErrorDict`` and ``ErrorList`` will be removed.
+
 .. _deprecation-removed-in-4.1:
 
 4.1

+ 173 - 33
docs/ref/forms/api.txt

@@ -520,13 +520,41 @@ Although ``<table>`` output is the default output style when you ``print`` a
 form, other output styles are available. Each style is available as a method on
 a form object, and each rendering method returns a string.
 
+``template_name``
+-----------------
+
+.. versionadded:: 4.0
+
+.. attribute:: Form.template_name
+
+The name of a template that is going to be rendered if the form is cast into a
+string, e.g. via ``print(form)`` or in a template via ``{{ form }}``. By
+default this template is ``'django/forms/default.html'``, which is a proxy for
+``'django/forms/table.html'``. The template can be changed per form by
+overriding the ``template_name`` attribute or more generally by overriding the
+default template, see also :ref:`overriding-built-in-form-templates`.
+
+``template_name_label``
+-----------------------
+
+.. versionadded:: 4.0
+
+.. attribute:: Form.template_name_label
+
+The template used to render a field's ``<label>``, used when calling
+:meth:`BoundField.label_tag`. Can be changed per form by overriding this
+attribute or more generally by overriding the default template, see also
+:ref:`overriding-built-in-form-templates`.
+
 ``as_p()``
 ----------
 
 .. method:: Form.as_p()
 
-``as_p()`` renders the form as a series of ``<p>`` tags, with each ``<p>``
-containing one field::
+``as_p()`` renders the form using the template assigned to the forms
+``template_name_p`` attribute, by default this template is
+``'django/forms/p.html'``. This template renders the form as a series of
+``<p>`` tags, with each ``<p>`` containing one field::
 
     >>> f = ContactForm()
     >>> f.as_p()
@@ -542,10 +570,12 @@ containing one field::
 
 .. method:: Form.as_ul()
 
-``as_ul()`` renders the form as a series of ``<li>`` tags, with each
-``<li>`` containing one field. It does *not* include the ``<ul>`` or
-``</ul>``, so that you can specify any HTML attributes on the ``<ul>`` for
-flexibility::
+``as_ul()`` renders the form using the template assigned to the forms
+``template_name_ul`` attribute, by default this template is
+``'django/forms/ul.html'``. This template renders the form as a series of
+``<li>`` tags, with each ``<li>`` containing one field. It does *not* include
+the ``<ul>`` or ``</ul>``, so that you can specify any HTML attributes on the
+``<ul>`` for flexibility::
 
     >>> f = ContactForm()
     >>> f.as_ul()
@@ -561,9 +591,10 @@ flexibility::
 
 .. method:: Form.as_table()
 
-Finally, ``as_table()`` outputs the form as an HTML ``<table>``. This is
-exactly the same as ``print``. In fact, when you ``print`` a form object,
-it calls its ``as_table()`` method behind the scenes::
+Finally, ``as_table()`` renders the form using the template assigned to the
+forms ``template_name_table`` attribute, by default this template is
+``'django/forms/table.html'``. This template outputs the form as an HTML
+``<table>``::
 
     >>> f = ContactForm()
     >>> f.as_table()
@@ -574,6 +605,37 @@ it calls its ``as_table()`` method behind the scenes::
     <tr><th><label for="id_sender">Sender:</label></th><td><input type="email" name="sender" id="id_sender" required></td></tr>
     <tr><th><label for="id_cc_myself">Cc myself:</label></th><td><input type="checkbox" name="cc_myself" id="id_cc_myself"></td></tr>
 
+``get_context()``
+-----------------
+
+.. versionadded:: 4.0
+
+.. method:: Form.get_context()
+
+Return context for form rendering in a template.
+
+The available context is:
+
+* ``form``: The bound form.
+* ``fields``: All bound fields, except the hidden fields.
+* ``hidden_fields``: All hidden bound fields.
+* ``errors``: All non field related or hidden field related form errors.
+
+``render()``
+------------
+
+.. versionadded:: 4.0
+
+.. method:: Form.render(template_name=None, context=None, renderer=None)
+
+The render method is called by ``__str__`` as well as the
+:meth:`.Form.as_table`, :meth:`.Form.as_p`, and :meth:`.Form.as_ul` methods.
+All arguments are optional and default to:
+
+* ``template_name``: :attr:`.Form.template_name`
+* ``context``: Value returned by :meth:`.Form.get_context`
+* ``renderer``: Value returned by :attr:`.Form.default_renderer`
+
 .. _ref-forms-api-styling-form-rows:
 
 Styling required or erroneous form rows
@@ -834,25 +896,99 @@ method you're using::
 Customizing the error list format
 ---------------------------------
 
-By default, forms use ``django.forms.utils.ErrorList`` to format validation
-errors. If you'd like to use an alternate class for displaying errors, you can
-pass that in at construction time::
-
-    >>> from django.forms.utils import ErrorList
-    >>> class DivErrorList(ErrorList):
-    ...     def __str__(self):
-    ...         return self.as_divs()
-    ...     def as_divs(self):
-    ...         if not self: return ''
-    ...         return '<div class="errorlist">%s</div>' % ''.join(['<div class="error">%s</div>' % e for e in self])
-    >>> f = ContactForm(data, auto_id=False, error_class=DivErrorList)
-    >>> f.as_p()
-    <div class="errorlist"><div class="error">This field is required.</div></div>
-    <p>Subject: <input type="text" name="subject" maxlength="100" required></p>
-    <p>Message: <input type="text" name="message" value="Hi there" required></p>
-    <div class="errorlist"><div class="error">Enter a valid email address.</div></div>
-    <p>Sender: <input type="email" name="sender" value="invalid email address" required></p>
-    <p>Cc myself: <input checked type="checkbox" name="cc_myself"></p>
+.. class:: ErrorList(initlist=None, error_class=None, renderer=None)
+
+    By default, forms use ``django.forms.utils.ErrorList`` to format validation
+    errors. ``ErrorList`` is a list like object where ``initlist`` is the
+    list of errors. In addition this class has the following attributes and
+    methods.
+
+    .. attribute:: error_class
+
+        The CSS classes to be used when rendering the error list. Any provided
+        classes are added to the default ``errorlist`` class.
+
+    .. attribute:: renderer
+
+        .. versionadded:: 4.0
+
+        Specifies the :doc:`renderer <renderers>` to use for ``ErrorList``.
+        Defaults to ``None`` which means to use the default renderer
+        specified by the :setting:`FORM_RENDERER` setting.
+
+    .. attribute:: template_name
+
+        .. versionadded:: 4.0
+
+        The name of the template used when calling ``__str__`` or
+        :meth:`render`. By default this is
+        ``'django/forms/errors/list/default.html'`` which is a proxy for the
+        ``'ul.html'`` template.
+
+    .. attribute:: template_name_text
+
+        .. versionadded:: 4.0
+
+        The name of the template used when calling :meth:`.as_text`. By default
+        this is ``'django/forms/errors/list/text.html'``. This template renders
+        the errors as a list of bullet points.
+
+    .. attribute:: template_name_ul
+
+        .. versionadded:: 4.0
+
+        The name of the template used when calling :meth:`.as_ul`. By default
+        this is ``'django/forms/errors/list/ul.html'``. This template renders
+        the errors in ``<li>`` tags with a wrapping ``<ul>`` with the CSS
+        classes as defined by :attr:`.error_class`.
+
+    .. method:: get_context()
+
+        .. versionadded:: 4.0
+
+        Return context for rendering of errors in a template.
+
+        The available context is:
+
+        * ``errors`` : A list of the errors.
+        * ``error_class`` : A string of CSS classes.
+
+    .. method:: render(template_name=None, context=None, renderer=None)
+
+        .. versionadded:: 4.0
+
+        The render method is called by ``__str__`` as well as by the
+        :meth:`.as_ul` method.
+
+        All arguments are optional and will default to:
+
+        * ``template_name``: Value returned by :attr:`.template_name`
+        * ``context``: Value returned by :meth:`.get_context`
+        * ``renderer``: Value returned by :attr:`.renderer`
+
+    .. method:: as_text()
+
+        Renders the error list using the template defined by
+        :attr:`.template_name_text`.
+
+    .. method:: as_ul()
+
+        Renders the error list using the template defined by
+        :attr:`.template_name_ul`.
+
+    If you'd like to customize the rendering of errors this can be achieved by
+    overriding the :attr:`.template_name` attribute or more generally by
+    overriding the default template, see also
+    :ref:`overriding-built-in-form-templates`.
+
+.. versionchanged:: 4.0
+
+    Rendering of :class:`ErrorList` was moved to the template engine.
+
+.. deprecated:: 4.0
+
+    The ability to return a ``str`` when calling the ``__str__`` method is
+    deprecated. Use the template engine instead which returns a ``SafeString``.
 
 More granular output
 ====================
@@ -1086,12 +1222,16 @@ Methods of ``BoundField``
     attributes for the ``<label>`` tag.
 
     The HTML that's generated includes the form's
-    :attr:`~django.forms.Form.label_suffix` (a colon, by default) or, if set, the
-    current field's :attr:`~django.forms.Field.label_suffix`. The optional
+    :attr:`~django.forms.Form.label_suffix` (a colon, by default) or, if set,
+    the current field's :attr:`~django.forms.Field.label_suffix`. The optional
     ``label_suffix`` parameter allows you to override any previously set
-    suffix. For example, you can use an empty string to hide the label on selected
-    fields. If you need to do this in a template, you could write a custom
-    filter to allow passing parameters to ``label_tag``.
+    suffix. For example, you can use an empty string to hide the label on
+    selected fields. The label is rendered using the template specified by the
+    forms :attr:`.Form.template_name_label`.
+
+    .. versionchanged:: 4.0
+
+        The label is now rendered using the template engine.
 
 .. method:: BoundField.value()
 

+ 5 - 1
docs/ref/forms/formsets.txt

@@ -11,7 +11,7 @@ Formset API reference. For introductory material about formsets, see the
 ``formset_factory``
 ===================
 
-.. function:: formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, min_num=None, validate_min=False, absolute_max=None, can_delete_extra=True)
+.. function:: formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, min_num=None, validate_min=False, absolute_max=None, can_delete_extra=True, renderer=None)
 
     Returns a ``FormSet`` class for the given ``form`` class.
 
@@ -20,3 +20,7 @@ Formset API reference. For introductory material about formsets, see the
     .. versionchanged:: 3.2
 
         The ``absolute_max`` and ``can_delete_extra`` arguments were added.
+
+    .. versionchanged:: 4.0
+
+        The ``renderer`` argument was added.

+ 13 - 5
docs/ref/forms/models.txt

@@ -52,7 +52,7 @@ Model Form API reference. For introductory material about model forms, see the
 ``modelformset_factory``
 ========================
 
-.. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None, can_delete_extra=True)
+.. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None, can_delete_extra=True, renderer=None)
 
     Returns a ``FormSet`` class for the given ``model`` class.
 
@@ -63,9 +63,9 @@ Model Form API reference. For introductory material about model forms, see the
 
     Arguments ``formset``, ``extra``, ``can_delete``, ``can_order``,
     ``max_num``, ``validate_max``, ``min_num``, ``validate_min``,
-    ``absolute_max``, and ``can_delete_extra`` are passed through to
-    :func:`~django.forms.formsets.formset_factory`. See :doc:`formsets
-    </topics/forms/formsets>` for details.
+    ``absolute_max``, ``can_delete_extra``, and ``renderer`` are passed
+    through to :func:`~django.forms.formsets.formset_factory`. See
+    :doc:`formsets </topics/forms/formsets>` for details.
 
     See :ref:`model-formsets` for example usage.
 
@@ -73,10 +73,14 @@ Model Form API reference. For introductory material about model forms, see the
 
         The ``absolute_max`` and ``can_delete_extra`` arguments were added.
 
+    .. versionchanged:: 4.0
+
+        The ``renderer`` argument was added.
+
 ``inlineformset_factory``
 =========================
 
-.. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None, can_delete_extra=True)
+.. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None, can_delete_extra=True, renderer=None)
 
     Returns an ``InlineFormSet`` using :func:`modelformset_factory` with
     defaults of ``formset=``:class:`~django.forms.models.BaseInlineFormSet`,
@@ -90,3 +94,7 @@ Model Form API reference. For introductory material about model forms, see the
     .. versionchanged:: 3.2
 
         The ``absolute_max`` and ``can_delete_extra`` arguments were added.
+
+    .. versionchanged:: 4.0
+
+        The ``renderer`` argument was added.

+ 54 - 5
docs/ref/forms/renderers.txt

@@ -68,11 +68,11 @@ it uses a :class:`~django.template.backends.jinja2.Jinja2` backend. Templates
 for the built-in widgets are located in ``django/forms/jinja2`` and installed
 apps can provide templates in a ``jinja2`` directory.
 
-To use this backend, all the widgets in your project and its third-party apps
-must have Jinja2 templates. Unless you provide your own Jinja2 templates for
-widgets that don't have any, you can't use this renderer. For example,
-:mod:`django.contrib.admin` doesn't include Jinja2 templates for its widgets
-due to their usage of Django template tags.
+To use this backend, all the forms and widgets in your project and its
+third-party apps must have Jinja2 templates. Unless you provide your own Jinja2
+templates for widgets that don't have any, you can't use this renderer. For
+example, :mod:`django.contrib.admin` doesn't include Jinja2 templates for its
+widgets due to their usage of Django template tags.
 
 ``TemplatesSetting``
 --------------------
@@ -97,6 +97,29 @@ Using this renderer along with the built-in widget templates requires either:
 Using this renderer requires you to make sure the form templates your project
 needs can be located.
 
+Context available in formset templates
+======================================
+
+.. versionadded:: 4.0
+
+Formset templates receive a context from :meth:`.BaseFormSet.get_context`. By
+default, formsets receive a dictionary with the following values:
+
+* ``formset``: The formset instance.
+
+Context available in form templates
+===================================
+
+.. versionadded:: 4.0
+
+Form templates receive a context from :meth:`.Form.get_context`. By default,
+forms receive a dictionary with the following values:
+
+* ``form``: The bound form.
+* ``fields``: All bound fields, except the hidden fields.
+* ``hidden_fields``: All hidden bound fields.
+* ``errors``: All non field related or hidden field related form errors.
+
 Context available in widget templates
 =====================================
 
@@ -114,6 +137,32 @@ Some widgets add further information to the context. For instance, all widgets
 that subclass ``Input`` defines ``widget['type']`` and :class:`.MultiWidget`
 defines ``widget['subwidgets']`` for looping purposes.
 
+.. _overriding-built-in-formset-templates:
+
+Overriding built-in formset templates
+=====================================
+
+.. versionadded:: 4.0
+
+:attr:`.BaseFormSet.template_name`
+
+To override formset templates, you must use the :class:`TemplatesSetting`
+renderer. Then overriding widget templates works :doc:`the same as
+</howto/overriding-templates>` overriding any other template in your project.
+
+.. _overriding-built-in-form-templates:
+
+Overriding built-in form templates
+==================================
+
+.. versionadded:: 4.0
+
+:attr:`.Form.template_name`
+
+To override form templates, you must use the :class:`TemplatesSetting`
+renderer. Then overriding widget templates works :doc:`the same as
+</howto/overriding-templates>` overriding any other template in your project.
+
 .. _overriding-built-in-widget-templates:
 
 Overriding built-in widget templates

+ 3 - 2
docs/ref/settings.txt

@@ -1671,8 +1671,9 @@ generate correct URLs when ``SCRIPT_NAME`` is not ``/``.
 
 Default: ``'``:class:`django.forms.renderers.DjangoTemplates`\ ``'``
 
-The class that renders form widgets. It must implement :ref:`the low-level
-render API <low-level-widget-render-api>`. Included form renderers are:
+The class that renders forms and form widgets. It must implement
+:ref:`the low-level render API <low-level-widget-render-api>`. Included form
+renderers are:
 
 * ``'``:class:`django.forms.renderers.DjangoTemplates`\ ``'``
 * ``'``:class:`django.forms.renderers.Jinja2`\ ``'``

+ 17 - 0
docs/releases/4.0.txt

@@ -115,6 +115,17 @@ in Django <redis>`.
 
 .. _`redis-py`: https://pypi.org/project/redis/
 
+Template based form rendering
+-----------------------------
+
+To enhance customization of :class:`Forms <django.forms.Form>`,
+:doc:`Formsets </topics/forms/formsets>`, and
+:class:`~django.forms.ErrorList` they are now rendered using the template
+engine. See the new :meth:`~django.forms.Form.render`,
+:meth:`~django.forms.Form.get_context`, and
+:attr:`~django.forms.Form.template_name` for ``Form`` and
+:ref:`formset rendering <formset-rendering>` for ``Formset``.
+
 Minor features
 --------------
 
@@ -735,6 +746,12 @@ Miscellaneous
   are deprecated. Use :class:`~django.contrib.admin.ModelAdmin` and
   :class:`~django.contrib.gis.admin.GISModelAdmin` instead.
 
+* Since form rendering now uses the template engine, the undocumented
+  ``BaseForm._html_output()`` helper method is deprecated.
+
+* The ability to return a ``str`` from ``ErrorList`` and ``ErrorDict`` is
+  deprecated. It is expected these methods return a ``SafeString``.
+
 Features removed in 4.0
 =======================
 

+ 94 - 1
docs/topics/forms/formsets.txt

@@ -775,9 +775,92 @@ But with ``ArticleFormset(prefix='article')`` that becomes:
 This is useful if you want to :ref:`use more than one formset in a view
 <multiple-formsets-in-view>`.
 
+.. _formset-rendering:
+
 Using a formset in views and templates
 ======================================
 
+Formsets have five attributes and five methods associated with rendering.
+
+.. attribute:: BaseFormSet.renderer
+
+    .. versionadded:: 4.0
+
+    Specifies the :doc:`renderer </ref/forms/renderers>` to use for the
+    formset. Defaults to the renderer specified by the :setting:`FORM_RENDERER`
+    setting.
+
+.. attribute:: BaseFormSet.template_name
+
+    .. versionadded:: 4.0
+
+    The name of the template used when calling ``__str__`` or :meth:`.render`.
+    This template renders the formsets management forms and then each form in
+    the formset as per the template defined by the
+    forms :attr:`~django.forms.Form.template_name`. This is a proxy of
+    ``as_table`` by default.
+
+.. attribute:: BaseFormSet.template_name_p
+
+    .. versionadded:: 4.0
+
+    The name of the template used when calling :meth:`.as_p`. By default this
+    is ``'django/forms/formsets/p.html'``. This template renders the formsets
+    management forms and then each form in the formset as per the forms
+    :meth:`~django.forms.Form.as_p` method.
+
+.. attribute:: BaseFormSet.template_name_table
+
+    .. versionadded:: 4.0
+
+    The name of the template used when calling :meth:`.as_table`. By default
+    this is ``'django/forms/formsets/table.html'``. This template renders the
+    formsets management forms and then each form in the formset as per the
+    forms :meth:`~django.forms.Form.as_table` method.
+
+.. attribute:: BaseFormSet.template_name_ul
+
+    .. versionadded:: 4.0
+
+    The name of the template used when calling :meth:`.as_ul`. By default this
+    is ``'django/forms/formsets/ul.html'``. This template renders the formsets
+    management forms and then each form in the formset as per the forms
+    :meth:`~django.forms.Form.as_ul` method.
+
+.. method:: BaseFormSet.get_context()
+
+    .. versionadded:: 4.0
+
+    Returns the context for rendering a formset in a template.
+
+    The available context is:
+
+    * ``formset`` : The instance of the formset.
+
+.. method:: BaseFormSet.render(template_name=None, context=None, renderer=None)
+
+    .. versionadded:: 4.0
+
+    The render method is called by ``__str__`` as well as the :meth:`.as_p`,
+    :meth:`.as_ul`, and :meth:`.as_table` methods. All arguments are optional
+    and will default to:
+
+    * ``template_name``: :attr:`.template_name`
+    * ``context``: Value returned by :meth:`.get_context`
+    * ``renderer``: Value returned by :attr:`.renderer`
+
+.. method:: BaseFormSet.as_p()
+
+    Renders the formset with the :attr:`.template_name_p` template.
+
+.. method:: BaseFormSet.as_table()
+
+    Renders the formset with the :attr:`.template_name_table` template.
+
+.. method:: BaseFormSet.as_ul()
+
+    Renders the formset with the :attr:`.template_name_ul` template.
+
 Using a formset inside a view is not very different from using a regular
 ``Form`` class. The only thing you will want to be aware of is making sure to
 use the management form inside the template. Let's look at a sample view::
@@ -821,7 +904,17 @@ deal with the management form:
         </table>
     </form>
 
-The above ends up calling the ``as_table`` method on the formset class.
+The above ends up calling the :meth:`BaseFormSet.render` method on the formset
+class. This renders the formset using the template specified by the
+:attr:`~BaseFormSet.template_name` attribute. Similar to forms, by default the
+formset will be rendered ``as_table``, with other helper methods of ``as_p``
+and ``as_ul`` being available. The rendering of the formset can be customized
+by specifying the ``template_name`` attribute, or more generally by
+:ref:`overriding the default template <overriding-built-in-formset-templates>`.
+
+.. versionchanged:: 4.0
+
+    Rendering of formsets was moved to the template engine.
 
 .. _manually-rendered-can-delete-and-can-order:
 

+ 14 - 10
docs/topics/forms/index.txt

@@ -733,12 +733,17 @@ Reusable form templates
 
 If your site uses the same rendering logic for forms in multiple places, you
 can reduce duplication by saving the form's loop in a standalone template and
-using the :ttag:`include` tag to reuse it in other templates:
+overriding the forms :attr:`~django.forms.Form.template_name` attribute to
+render the form using the custom template. The below example will result in
+``{{ form }}`` being rendered as the output of the ``form_snippet.html``
+template.
+
+In your templates:
 
 .. code-block:: html+django
 
-    # In your form template:
-    {% include "form_snippet.html" %}
+    # In your template:
+    {{ form }}
 
     # In form_snippet.html:
     {% for field in form %}
@@ -748,16 +753,15 @@ using the :ttag:`include` tag to reuse it in other templates:
         </div>
     {% endfor %}
 
-If the form object passed to a template has a different name within the
-context, you can alias it using the ``with`` argument of the :ttag:`include`
-tag:
+In your form::
 
-.. code-block:: html+django
+    class MyForm(forms.Form):
+        template_name = 'form_snippet.html'
+        ...
 
-    {% include "form_snippet.html" with form=comment_form %}
+.. versionchanged:: 4.0
 
-If you find yourself doing this often, you might consider creating a custom
-:ref:`inclusion tag<howto-custom-template-tags-inclusion-tags>`.
+    Template rendering of forms was added.
 
 Further topics
 ==============

+ 1 - 1
tests/admin_views/tests.py

@@ -6374,7 +6374,7 @@ class AdminViewOnSiteTests(TestCase):
             response, 'inline_admin_formset', 0, None,
             ['Children must share a family name with their parents in this contrived test case']
         )
-        msg = "The formset 'inline_admin_formset' in context 12 does not contain any non-form errors."
+        msg = "The formset 'inline_admin_formset' in context 22 does not contain any non-form errors."
         with self.assertRaisesMessage(AssertionError, msg):
             self.assertFormsetError(response, 'inline_admin_formset', None, None, ['Error'])
 

+ 1 - 0
tests/forms_tests/templates/forms_tests/error.html

@@ -0,0 +1 @@
+{% if errors %}<div class="errorlist">{% for error in errors %}<div class="error">{{ error }}</div>{% endfor %}</div>{% endif %}

+ 6 - 0
tests/forms_tests/templates/forms_tests/form_snippet.html

@@ -0,0 +1,6 @@
+{% for field in form %}
+  <div class="fieldWrapper">
+    {{ field.errors }}
+    {{ field.label_tag }} {{ field }}
+  </div>
+{% endfor %}

+ 30 - 0
tests/forms_tests/tests/__init__.py

@@ -0,0 +1,30 @@
+import inspect
+
+from django.test.utils import override_settings
+
+TEST_SETTINGS = [
+    {
+        'FORM_RENDERER': 'django.forms.renderers.DjangoTemplates',
+        'TEMPLATES': {'BACKEND': 'django.template.backends.django.DjangoTemplates'},
+    },
+    {
+        'FORM_RENDERER': 'django.forms.renderers.Jinja2',
+        'TEMPLATES': {'BACKEND': 'django.template.backends.jinja2.Jinja2'},
+    },
+]
+
+
+def test_all_form_renderers():
+    def wrapper(func):
+        def inner(*args, **kwargs):
+            for settings in TEST_SETTINGS:
+                with override_settings(**settings):
+                    func(*args, **kwargs)
+        return inner
+
+    def decorator(cls):
+        for name, func in inspect.getmembers(cls, inspect.isfunction):
+            if name.startswith('test_'):
+                setattr(cls, name, wrapper(func))
+        return cls
+    return decorator

+ 183 - 0
tests/forms_tests/tests/test_deprecation_forms.py

@@ -0,0 +1,183 @@
+# RemovedInDjango50
+from django.forms import CharField, EmailField, Form, HiddenInput
+from django.forms.utils import ErrorList
+from django.test import SimpleTestCase, ignore_warnings
+from django.utils.deprecation import RemovedInDjango50Warning
+
+from .test_forms import Person
+
+
+class DivErrorList(ErrorList):
+    def __str__(self):
+        return self.as_divs()
+
+    def as_divs(self):
+        if not self:
+            return ''
+        return '<div class="errorlist">%s</div>' % ''.join(
+            f'<div class="error">{error}</div>' for error in self
+        )
+
+
+class DeprecationTests(SimpleTestCase):
+    def test_deprecation_warning_html_output(self):
+        msg = (
+            'django.forms.BaseForm._html_output() is deprecated. Please use '
+            '.render() and .get_context() instead.'
+        )
+        with self.assertRaisesMessage(RemovedInDjango50Warning, msg):
+            form = Person()
+            form._html_output(
+                normal_row='<p id="p_%(field_name)s"></p>',
+                error_row='%s',
+                row_ender='</p>',
+                help_text_html=' %s',
+                errors_on_separate_row=True,
+            )
+
+    def test_deprecation_warning_error_list(self):
+        class EmailForm(Form):
+            email = EmailField()
+            comment = CharField()
+
+        data = {'email': 'invalid'}
+        f = EmailForm(data, error_class=DivErrorList)
+        msg = (
+            'Returning a plain string from DivErrorList is deprecated. Please '
+            'customize via the template system instead.'
+        )
+        with self.assertRaisesMessage(RemovedInDjango50Warning, msg):
+            f.as_p()
+
+
+@ignore_warnings(category=RemovedInDjango50Warning)
+class DeprecatedTests(SimpleTestCase):
+    def test_errorlist_override_str(self):
+        class CommentForm(Form):
+            name = CharField(max_length=50, required=False)
+            email = EmailField()
+            comment = CharField()
+
+        data = {'email': 'invalid'}
+        f = CommentForm(data, auto_id=False, error_class=DivErrorList)
+        self.assertHTMLEqual(
+            f.as_p(),
+            '<p>Name: <input type="text" name="name" maxlength="50"></p>'
+            '<div class="errorlist">'
+            '<div class="error">Enter a valid email address.</div></div>'
+            '<p>Email: <input type="email" name="email" value="invalid" required></p>'
+            '<div class="errorlist">'
+            '<div class="error">This field is required.</div></div>'
+            '<p>Comment: <input type="text" name="comment" required></p>',
+        )
+
+    def test_field_name(self):
+        """#5749 - `field_name` may be used as a key in _html_output()."""
+        class SomeForm(Form):
+            some_field = CharField()
+
+            def as_p(self):
+                return self._html_output(
+                    normal_row='<p id="p_%(field_name)s"></p>',
+                    error_row='%s',
+                    row_ender='</p>',
+                    help_text_html=' %s',
+                    errors_on_separate_row=True,
+                )
+
+        form = SomeForm()
+        self.assertHTMLEqual(form.as_p(), '<p id="p_some_field"></p>')
+
+    def test_field_without_css_classes(self):
+        """
+        `css_classes` may be used as a key in _html_output() (empty classes).
+        """
+        class SomeForm(Form):
+            some_field = CharField()
+
+            def as_p(self):
+                return self._html_output(
+                    normal_row='<p class="%(css_classes)s"></p>',
+                    error_row='%s',
+                    row_ender='</p>',
+                    help_text_html=' %s',
+                    errors_on_separate_row=True,
+                )
+
+        form = SomeForm()
+        self.assertHTMLEqual(form.as_p(), '<p class=""></p>')
+
+    def test_field_with_css_class(self):
+        """
+        `css_classes` may be used as a key in _html_output() (class comes
+        from required_css_class in this case).
+        """
+        class SomeForm(Form):
+            some_field = CharField()
+            required_css_class = 'foo'
+
+            def as_p(self):
+                return self._html_output(
+                    normal_row='<p class="%(css_classes)s"></p>',
+                    error_row='%s',
+                    row_ender='</p>',
+                    help_text_html=' %s',
+                    errors_on_separate_row=True,
+                )
+
+        form = SomeForm()
+        self.assertHTMLEqual(form.as_p(), '<p class="foo"></p>')
+
+    def test_field_name_with_hidden_input(self):
+        """
+        BaseForm._html_output() should merge all the hidden input fields and
+        put them in the last row.
+        """
+        class SomeForm(Form):
+            hidden1 = CharField(widget=HiddenInput)
+            custom = CharField()
+            hidden2 = CharField(widget=HiddenInput)
+
+            def as_p(self):
+                return self._html_output(
+                    normal_row='<p%(html_class_attr)s>%(field)s %(field_name)s</p>',
+                    error_row='%s',
+                    row_ender='</p>',
+                    help_text_html=' %s',
+                    errors_on_separate_row=True,
+                )
+
+        form = SomeForm()
+        self.assertHTMLEqual(
+            form.as_p(),
+            '<p><input id="id_custom" name="custom" type="text" required> custom'
+            '<input id="id_hidden1" name="hidden1" type="hidden">'
+            '<input id="id_hidden2" name="hidden2" type="hidden"></p>'
+        )
+
+    def test_field_name_with_hidden_input_and_non_matching_row_ender(self):
+        """
+        BaseForm._html_output() should merge all the hidden input fields and
+        put them in the last row ended with the specific row ender.
+        """
+        class SomeForm(Form):
+            hidden1 = CharField(widget=HiddenInput)
+            custom = CharField()
+            hidden2 = CharField(widget=HiddenInput)
+
+            def as_p(self):
+                return self._html_output(
+                    normal_row='<p%(html_class_attr)s>%(field)s %(field_name)s</p>',
+                    error_row='%s',
+                    row_ender='<hr><hr>',
+                    help_text_html=' %s',
+                    errors_on_separate_row=True,
+                )
+
+        form = SomeForm()
+        self.assertHTMLEqual(
+            form.as_p(),
+            '<p><input id="id_custom" name="custom" type="text" required> custom</p>\n'
+            '<input id="id_hidden1" name="hidden1" type="hidden">'
+            '<input id="id_hidden2" name="hidden2" type="hidden"><hr><hr>'
+        )

+ 39 - 135
tests/forms_tests/tests/test_forms.py

@@ -23,6 +23,7 @@ from django.test import SimpleTestCase
 from django.test.utils import override_settings
 from django.utils.datastructures import MultiValueDict
 from django.utils.safestring import mark_safe
+from tests.forms_tests.tests import test_all_form_renderers
 
 
 class FrameworkForm(Form):
@@ -55,6 +56,7 @@ class MultiValueDictLike(dict):
         return [self[key]]
 
 
+@test_all_form_renderers()
 class FormsTestCase(SimpleTestCase):
     # A Form is a collection of Fields. It knows how to validate a set of data and it
     # knows how to render itself in a couple of default ways (e.g., an HTML table).
@@ -3077,117 +3079,6 @@ Password: <input type="password" name="password" required>
 
         self.assertHTMLEqual(boundfield.label_tag(label_suffix='$'), '<label for="id_field">Field$</label>')
 
-    def test_field_name(self):
-        """#5749 - `field_name` may be used as a key in _html_output()."""
-        class SomeForm(Form):
-            some_field = CharField()
-
-            def as_p(self):
-                return self._html_output(
-                    normal_row='<p id="p_%(field_name)s"></p>',
-                    error_row='%s',
-                    row_ender='</p>',
-                    help_text_html=' %s',
-                    errors_on_separate_row=True,
-                )
-
-        form = SomeForm()
-        self.assertHTMLEqual(form.as_p(), '<p id="p_some_field"></p>')
-
-    def test_field_without_css_classes(self):
-        """
-        `css_classes` may be used as a key in _html_output() (empty classes).
-        """
-        class SomeForm(Form):
-            some_field = CharField()
-
-            def as_p(self):
-                return self._html_output(
-                    normal_row='<p class="%(css_classes)s"></p>',
-                    error_row='%s',
-                    row_ender='</p>',
-                    help_text_html=' %s',
-                    errors_on_separate_row=True,
-                )
-
-        form = SomeForm()
-        self.assertHTMLEqual(form.as_p(), '<p class=""></p>')
-
-    def test_field_with_css_class(self):
-        """
-        `css_classes` may be used as a key in _html_output() (class comes
-        from required_css_class in this case).
-        """
-        class SomeForm(Form):
-            some_field = CharField()
-            required_css_class = 'foo'
-
-            def as_p(self):
-                return self._html_output(
-                    normal_row='<p class="%(css_classes)s"></p>',
-                    error_row='%s',
-                    row_ender='</p>',
-                    help_text_html=' %s',
-                    errors_on_separate_row=True,
-                )
-
-        form = SomeForm()
-        self.assertHTMLEqual(form.as_p(), '<p class="foo"></p>')
-
-    def test_field_name_with_hidden_input(self):
-        """
-        BaseForm._html_output() should merge all the hidden input fields and
-        put them in the last row.
-        """
-        class SomeForm(Form):
-            hidden1 = CharField(widget=HiddenInput)
-            custom = CharField()
-            hidden2 = CharField(widget=HiddenInput)
-
-            def as_p(self):
-                return self._html_output(
-                    normal_row='<p%(html_class_attr)s>%(field)s %(field_name)s</p>',
-                    error_row='%s',
-                    row_ender='</p>',
-                    help_text_html=' %s',
-                    errors_on_separate_row=True,
-                )
-
-        form = SomeForm()
-        self.assertHTMLEqual(
-            form.as_p(),
-            '<p><input id="id_custom" name="custom" type="text" required> custom'
-            '<input id="id_hidden1" name="hidden1" type="hidden">'
-            '<input id="id_hidden2" name="hidden2" type="hidden"></p>'
-        )
-
-    def test_field_name_with_hidden_input_and_non_matching_row_ender(self):
-        """
-        BaseForm._html_output() should merge all the hidden input fields and
-        put them in the last row ended with the specific row ender.
-        """
-        class SomeForm(Form):
-            hidden1 = CharField(widget=HiddenInput)
-            custom = CharField()
-            hidden2 = CharField(widget=HiddenInput)
-
-            def as_p(self):
-                return self._html_output(
-                    normal_row='<p%(html_class_attr)s>%(field)s %(field_name)s</p>',
-                    error_row='%s',
-                    row_ender='<hr><hr>',
-                    help_text_html=' %s',
-                    errors_on_separate_row=True
-                )
-
-        form = SomeForm()
-        self.assertHTMLEqual(
-            form.as_p(),
-            '<p><input id="id_custom" name="custom" type="text" required> custom</p>\n'
-            '<input id="id_hidden1" name="hidden1" type="hidden">'
-            '<input id="id_hidden2" name="hidden2" type="hidden"><hr><hr>'
-        )
-
     def test_error_dict(self):
         class MyForm(Form):
             foo = CharField()
@@ -3377,30 +3268,6 @@ Password: <input type="password" name="password" required>
 <input id="id_last_name" name="last_name" type="text" value="Lennon" required></td></tr>"""
         )
 
-    def test_errorlist_override(self):
-        class DivErrorList(ErrorList):
-            def __str__(self):
-                return self.as_divs()
-
-            def as_divs(self):
-                if not self:
-                    return ''
-                return '<div class="errorlist">%s</div>' % ''.join(
-                    '<div class="error">%s</div>' % e for e in self)
-
-        class CommentForm(Form):
-            name = CharField(max_length=50, required=False)
-            email = EmailField()
-            comment = CharField()
-
-        data = {'email': 'invalid'}
-        f = CommentForm(data, auto_id=False, error_class=DivErrorList)
-        self.assertHTMLEqual(f.as_p(), """<p>Name: <input type="text" name="name" maxlength="50"></p>
-<div class="errorlist"><div class="error">Enter a valid email address.</div></div>
-<p>Email: <input type="email" name="email" value="invalid" required></p>
-<div class="errorlist"><div class="error">This field is required.</div></div>
-<p>Comment: <input type="text" name="comment" required></p>""")
-
     def test_error_escaping(self):
         class TestForm(Form):
             hidden = CharField(widget=HiddenInput(), required=False)
@@ -4045,3 +3912,40 @@ class TemplateTests(SimpleTestCase):
             "VALID: [('password1', 'secret'), ('password2', 'secret'), "
             "('username', 'adrian')]",
         )
+
+
+class OverrideTests(SimpleTestCase):
+    def test_use_custom_template(self):
+        class Person(Form):
+            first_name = CharField()
+            template_name = 'forms_tests/form_snippet.html'
+
+        t = Template('{{ form }}')
+        html = t.render(Context({'form': Person()}))
+        expected = """
+        <div class="fieldWrapper"><label for="id_first_name">First name:</label>
+        <input type="text" name="first_name" required id="id_first_name"></div>
+        """
+        self.assertHTMLEqual(html, expected)
+
+    def test_errorlist_override(self):
+        class CustomErrorList(ErrorList):
+            template_name = 'forms_tests/error.html'
+
+        class CommentForm(Form):
+            name = CharField(max_length=50, required=False)
+            email = EmailField()
+            comment = CharField()
+
+        data = {'email': 'invalid'}
+        f = CommentForm(data, auto_id=False, error_class=CustomErrorList)
+        self.assertHTMLEqual(
+            f.as_p(),
+            '<p>Name: <input type="text" name="name" maxlength="50"></p>'
+            '<div class="errorlist">'
+            '<div class="error">Enter a valid email address.</div></div>'
+            '<p>Email: <input type="email" name="email" value="invalid" required></p>'
+            '<div class="errorlist">'
+            '<div class="error">This field is required.</div></div>'
+            '<p>Comment: <input type="text" name="comment" required></p>',
+        )

+ 30 - 2
tests/forms_tests/tests/test_formsets.py

@@ -11,6 +11,7 @@ from django.forms.formsets import BaseFormSet, all_valid, formset_factory
 from django.forms.utils import ErrorList
 from django.forms.widgets import HiddenInput
 from django.test import SimpleTestCase
+from tests.forms_tests.tests import test_all_form_renderers
 
 
 class Choice(Form):
@@ -47,6 +48,7 @@ class CustomKwargForm(Form):
         super().__init__(*args, **kwargs)
 
 
+@test_all_form_renderers()
 class FormsFormsetTestCase(SimpleTestCase):
 
     def make_choiceformset(
@@ -1288,7 +1290,32 @@ class FormsFormsetTestCase(SimpleTestCase):
         self.assertIs(formset._should_delete_form(formset.forms[1]), False)
         self.assertIs(formset._should_delete_form(formset.forms[2]), False)
 
+    def test_custom_renderer(self):
+        """
+        A custom renderer passed to a formset_factory() is passed to all forms
+        and ErrorList.
+        """
+        from django.forms.renderers import Jinja2
+        renderer = Jinja2()
+        data = {
+            'choices-TOTAL_FORMS': '2',
+            'choices-INITIAL_FORMS': '0',
+            'choices-MIN_NUM_FORMS': '0',
+            'choices-0-choice': 'Zero',
+            'choices-0-votes': '',
+            'choices-1-choice': 'One',
+            'choices-1-votes': '',
+        }
+        ChoiceFormSet = formset_factory(Choice, renderer=renderer)
+        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        self.assertEqual(formset.renderer, renderer)
+        self.assertEqual(formset.forms[0].renderer, renderer)
+        self.assertEqual(formset.management_form.renderer, renderer)
+        self.assertEqual(formset.non_form_errors().renderer, renderer)
+        self.assertEqual(formset.empty_form.renderer, renderer)
+
 
+@test_all_form_renderers()
 class FormsetAsTagTests(SimpleTestCase):
     def setUp(self):
         data = {
@@ -1345,6 +1372,7 @@ class ArticleForm(Form):
 ArticleFormSet = formset_factory(ArticleForm)
 
 
+@test_all_form_renderers()
 class TestIsBoundBehavior(SimpleTestCase):
     def test_no_data_error(self):
         formset = ArticleFormSet({})
@@ -1359,7 +1387,7 @@ class TestIsBoundBehavior(SimpleTestCase):
         )
         self.assertEqual(formset.errors, [])
         # Can still render the formset.
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(formset),
             '<tr><td colspan="2">'
             '<ul class="errorlist nonfield">'
@@ -1390,7 +1418,7 @@ class TestIsBoundBehavior(SimpleTestCase):
         )
         self.assertEqual(formset.errors, [])
         # Can still render the formset.
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(formset),
             '<tr><td colspan="2">'
             '<ul class="errorlist nonfield">'

+ 2 - 0
tests/forms_tests/tests/test_i18n.py

@@ -4,8 +4,10 @@ from django.forms import (
 from django.test import SimpleTestCase
 from django.utils import translation
 from django.utils.translation import gettext_lazy
+from tests.forms_tests.tests import test_all_form_renderers
 
 
+@test_all_form_renderers()
 class FormsI18nTests(SimpleTestCase):
     def test_lazy_labels(self):
         class SomeForm(Form):

+ 2 - 0
tests/forms_tests/tests/tests.py

@@ -5,6 +5,7 @@ from django.db import models
 from django.forms import CharField, FileField, Form, ModelForm
 from django.forms.models import ModelFormMetaclass
 from django.test import SimpleTestCase, TestCase
+from tests.forms_tests.tests import test_all_form_renderers
 
 from ..models import (
     BoundaryModel, ChoiceFieldModel, ChoiceModel, ChoiceOptionModel, Defaults,
@@ -283,6 +284,7 @@ class ManyToManyExclusionTestCase(TestCase):
         self.assertEqual([obj.pk for obj in form.instance.multi_choice_int.all()], data['multi_choice_int'])
 
 
+@test_all_form_renderers()
 class EmptyLabelTestCase(TestCase):
     def test_empty_field_char(self):
         f = EmptyCharLabelChoiceForm()

+ 19 - 0
tests/model_formsets/tests.py

@@ -1994,3 +1994,22 @@ class TestModelFormsetOverridesTroughFormMeta(TestCase):
         self.assertEqual(len(formset), 2)
         self.assertNotIn('DELETE', formset.forms[0].fields)
         self.assertNotIn('DELETE', formset.forms[1].fields)
+
+    def test_inlineformset_factory_passes_renderer(self):
+        from django.forms.renderers import Jinja2
+        renderer = Jinja2()
+        BookFormSet = inlineformset_factory(
+            Author,
+            Book,
+            fields='__all__',
+            renderer=renderer,
+        )
+        formset = BookFormSet()
+        self.assertEqual(formset.renderer, renderer)
+
+    def test_modelformset_factory_passes_renderer(self):
+        from django.forms.renderers import Jinja2
+        renderer = Jinja2()
+        BookFormSet = modelformset_factory(Author, fields='__all__', renderer=renderer)
+        formset = BookFormSet()
+        self.assertEqual(formset.renderer, renderer)