Browse Source

Refs #32339 -- Deprecated default.html form template.

Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
David Smith 2 years ago
parent
commit
d126eba363

+ 28 - 0
django/forms/renderers.py

@@ -15,6 +15,9 @@ def get_default_renderer():
 
 
 class BaseRenderer:
+    # RemovedInDjango50Warning: When the deprecation ends, replace with
+    # form_template_name = "django/forms/div.html"
+    # formset_template_name = "django/forms/formsets/div.html"
     form_template_name = "django/forms/default.html"
     formset_template_name = "django/forms/formsets/default.html"
 
@@ -64,6 +67,31 @@ class Jinja2(EngineMixin, BaseRenderer):
         return Jinja2
 
 
+class DjangoDivFormRenderer(DjangoTemplates):
+    """
+    Load Django templates from django/forms/templates and from apps'
+    'templates' directory and use the 'div.html' template to render forms and
+    formsets.
+    """
+
+    # RemovedInDjango50Warning Deprecate this class in 5.0 and remove in 6.0.
+
+    form_template_name = "django/forms/div.html"
+    formset_template_name = "django/forms/formsets/div.html"
+
+
+class Jinja2DivFormRenderer(Jinja2):
+    """
+    Load Jinja2 templates from the built-in widget templates in
+    django/forms/jinja2 and from apps' 'jinja2' directory.
+    """
+
+    # RemovedInDjango50Warning Deprecate this class in 5.0 and remove in 6.0.
+
+    form_template_name = "django/forms/div.html"
+    formset_template_name = "django/forms/formsets/div.html"
+
+
 class TemplatesSetting(BaseRenderer):
     """
     Load templates using template.loader.get_template() which is configured

+ 23 - 5
django/forms/utils.py

@@ -1,13 +1,16 @@
 import json
+import warnings
 from collections import 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.deprecation import RemovedInDjango50Warning
 from django.utils.html import escape, format_html_join
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
+from django.utils.version import get_docs_version
 
 
 def pretty_name(name):
@@ -42,6 +45,16 @@ def flatatt(attrs):
     )
 
 
+DEFAULT_TEMPLATE_DEPRECATION_MSG = (
+    'The "default.html" templates for forms and formsets will be removed. These were '
+    'proxies to the equivalent "table.html" templates, but the new "div.html" '
+    "templates will be the default from Django 5.0. Transitional renderers are "
+    "provided to allow you to opt-in to the new output style now. See "
+    "https://docs.djangoproject.com/en/%s/releases/4.1/ for more details"
+    % get_docs_version()
+)
+
+
 class RenderableMixin:
     def get_context(self):
         raise NotImplementedError(
@@ -49,12 +62,17 @@ class RenderableMixin:
         )
 
     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(),
+        renderer = renderer or self.renderer
+        template = template_name or self.template_name
+        context = context or self.get_context()
+        if (
+            template == "django/forms/default.html"
+            or template == "django/forms/formsets/default.html"
+        ):
+            warnings.warn(
+                DEFAULT_TEMPLATE_DEPRECATION_MSG, RemovedInDjango50Warning, stacklevel=2
             )
-        )
+        return mark_safe(renderer.render(template, context))
 
     __str__ = render
     __html__ = render

+ 3 - 0
docs/internals/deprecation.txt

@@ -105,6 +105,9 @@ details on these changes.
 
 * The ``django.contrib.auth.hashers.CryptPasswordHasher`` will be removed.
 
+* The ``"django/forms/default.html"`` and
+  ``"django/forms/formsets/default.html"`` templates will be removed.
+
 * The ability to pass ``nulls_first=False`` or ``nulls_last=False`` to
   ``Expression.asc()`` and ``Expression.desc()`` methods, and the ``OrderBy``
   expression will be removed.

+ 43 - 0
docs/ref/forms/renderers.txt

@@ -56,6 +56,12 @@ should return a rendered templates (as a string) or raise
         Defaults to ``"django/forms/default.html"``, which is a proxy for
         ``"django/forms/table.html"``.
 
+        .. deprecated:: 4.1
+
+        The ``"django/forms/default.html"`` template is deprecated and will be
+        removed in Django 5.0. The default will become
+        ``"django/forms/default.html"`` at that time.
+
     .. attribute:: formset_template_name
 
         .. versionadded:: 4.1
@@ -65,6 +71,12 @@ should return a rendered templates (as a string) or raise
         Defaults to ``"django/forms/formsets/default.html"``, which is a proxy
         for ``"django/forms/formsets/table.html"``.
 
+        .. deprecated:: 4.1
+
+        The ``"django/forms/formset/default.html"`` template is deprecated and
+        will be removed in Django 5.0. The default will become
+        ``"django/forms/formset/div.html"`` template.
+
     .. method:: get_template(template_name)
 
         Subclasses must implement this method with the appropriate template
@@ -97,6 +109,26 @@ If you want to render templates with customizations from your
 :setting:`TEMPLATES` setting, such as context processors for example, use the
 :class:`TemplatesSetting` renderer.
 
+.. class:: DjangoDivFormRenderer
+
+.. versionadded:: 4.1
+
+Subclass of :class:`DjangoTemplates` that specifies
+:attr:`~BaseRenderer.form_template_name` and
+:attr:`~BaseRenderer.formset_template_name` as ``"django/forms/div.html"`` and
+``"django/forms/formset/div.html"`` respectively.
+
+This is a transitional renderer for opt-in to the new ``<div>`` based
+templates, which are the default from Django 5.0.
+
+Apply this via the :setting:`FORM_RENDERER` setting::
+
+    FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
+
+Once the ``<div>`` templates are the default, this transitional renderer will
+be deprecated, for removal in Django 6.0. The ``FORM_RENDERER`` declaration can
+be removed at that time.
+
 ``Jinja2``
 ----------
 
@@ -113,6 +145,17 @@ 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.
 
+.. class:: Jinja2DivFormRenderer
+
+.. versionadded:: 4.1
+
+A transitional renderer as per :class:`DjangoDivFormRenderer` above, but
+subclassing :class:`Jinja2` for use with the Jinja2 backend.
+
+Apply this via the :setting:`FORM_RENDERER` setting::
+
+    FORM_RENDERER = "django.forms.renderers.Jinja2DivFormRenderer"
+
 ``TemplatesSetting``
 --------------------
 

+ 52 - 0
docs/releases/4.1.txt

@@ -74,6 +74,24 @@ Validation of Constraints
 in the :attr:`Meta.constraints <django.db.models.Options.constraints>` option
 are now checked during :ref:`model validation <validating-objects>`.
 
+Form rendering accessibility
+----------------------------
+
+In order to aid users with screen readers, and other assistive technology, new
+``<div>`` based form templates are available from this release. These provide
+more accessible navigation than the older templates, and are able to correctly
+group related controls, such as radio-lists, into fieldsets.
+
+The new templates are recommended, and will become the default form rendering
+style when outputting a form, like ``{{ form }}`` in a template, from Django
+5.0.
+
+In order to ease adopting the new output style, the default form and formset
+templates are now configurable at the project level via the
+:setting:`FORM_RENDERER` setting.
+
+See :ref:`the Forms section (below)<forms-4.1>` for full details.
+
 .. _csrf-cookie-masked-usage:
 
 ``CSRF_COOKIE_MASKED`` setting
@@ -253,6 +271,8 @@ File Uploads
 
 * ...
 
+.. _forms-4.1:
+
 Forms
 ~~~~~
 
@@ -279,6 +299,34 @@ Forms
   as the template implements ``<fieldset>`` and ``<legend>`` to group related
   inputs and is easier for screen reader users to navigate.
 
+  The div-based output will become the default rendering style from Django 5.0.
+
+* In order to smooth adoption of the new ``<div>`` output style, two
+  transitional form renderer classes are available:
+  :class:`django.forms.renderers.DjangoDivFormRenderer` and
+  :class:`django.forms.renderers.Jinja2DivFormRenderer`, for the Django and
+  Jinja2 template backends respectively.
+
+  You can apply one of these via the :setting:`FORM_RENDERER` setting. For
+  example::
+
+    FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
+
+  Once the ``<div>`` output style is the default, from Django 5.0, these
+  transitional renderers will be deprecated, for removal in Django 6.0. The
+  ``FORM_RENDERER`` declaration can be removed at that time.
+
+* If the new ``<div>`` output style is not appropriate for your project, you should
+  define a renderer subclass specifying
+  :attr:`~django.forms.renderers.BaseRenderer.form_template_name` and
+  :attr:`~django.forms.renderers.BaseRenderer.formset_template_name` for your
+  required style, and set :setting:`FORM_RENDERER` accordingly.
+
+  For example, for the ``<p>`` output style used by :meth:`~.Form.as_p`, you
+  would define a form renderer setting ``form_template_name`` to
+  ``"django/forms/p.html"`` and ``formset_template_name`` to
+  ``"django/forms/formsets/p.html"``.
+
 * The new :meth:`~django.forms.BoundField.legend_tag` allows rendering field
   labels in ``<legend>`` tags via the new ``tag`` argument of
   :meth:`~django.forms.BoundField.label_tag`.
@@ -718,6 +766,10 @@ Miscellaneous
   ``Expression.asc()`` and ``Expression.desc()`` methods, and the ``OrderBy``
   expression is deprecated. Use ``None`` instead.
 
+* The ``"django/forms/default.html"`` and
+  ``"django/forms/formsets/default.html"`` templates which are a proxy to the
+  table-based templates are deprecated. Use the specific template instead.
+
 Features removed in 4.1
 =======================
 

+ 3 - 1
tests/forms_tests/tests/__init__.py

@@ -11,6 +11,8 @@ except ImportError:
 def jinja2_tests(test_func):
     test_func = skipIf(jinja2 is None, "this test requires jinja2")(test_func)
     return override_settings(
-        FORM_RENDERER="django.forms.renderers.Jinja2",
+        # RemovedInDjango50Warning: When the deprecation ends, revert to
+        # FORM_RENDERER="django.forms.renderers.Jinja2",
+        FORM_RENDERER="django.forms.renderers.Jinja2DivFormRenderer",
         TEMPLATES={"BACKEND": "django.template.backends.jinja2.Jinja2"},
     )(test_func)

+ 51 - 48
tests/forms_tests/tests/test_forms.py

@@ -42,8 +42,9 @@ from django.forms.utils import ErrorList
 from django.http import QueryDict
 from django.template import Context, Template
 from django.test import SimpleTestCase
-from django.test.utils import override_settings
+from django.test.utils import isolate_lru_cache, override_settings
 from django.utils.datastructures import MultiValueDict
+from django.utils.deprecation import RemovedInDjango50Warning
 from django.utils.safestring import mark_safe
 
 from . import jinja2_tests
@@ -149,17 +150,12 @@ class FormsTestCase(SimpleTestCase):
         )
         self.assertHTMLEqual(
             str(p),
-            """
-            <tr><th><label for="id_first_name">First name:</label></th><td>
-            <input type="text" name="first_name" value="John" id="id_first_name"
-                required></td></tr>
-            <tr><th><label for="id_last_name">Last name:</label></th><td>
-            <input type="text" name="last_name" value="Lennon" id="id_last_name"
-                required></td></tr>
-            <tr><th><label for="id_birthday">Birthday:</label></th><td>
-            <input type="text" name="birthday" value="1940-10-9" id="id_birthday"
-                required></td></tr>
-            """,
+            '<div><label for="id_first_name">First name:</label><input type="text" '
+            'name="first_name" value="John" required id="id_first_name"></div><div>'
+            '<label for="id_last_name">Last name:</label><input type="text" '
+            'name="last_name" value="Lennon" required id="id_last_name"></div><div>'
+            '<label for="id_birthday">Birthday:</label><input type="text" '
+            'name="birthday" value="1940-10-9" required id="id_birthday"></div>',
         )
         self.assertHTMLEqual(
             p.as_div(),
@@ -182,15 +178,15 @@ class FormsTestCase(SimpleTestCase):
         self.assertEqual(p.cleaned_data, {})
         self.assertHTMLEqual(
             str(p),
-            """<tr><th><label for="id_first_name">First name:</label></th><td>
-<ul class="errorlist"><li>This field is required.</li></ul>
-<input type="text" name="first_name" id="id_first_name" required></td></tr>
-<tr><th><label for="id_last_name">Last name:</label></th>
-<td><ul class="errorlist"><li>This field is required.</li></ul>
-<input type="text" name="last_name" id="id_last_name" required></td></tr>
-<tr><th><label for="id_birthday">Birthday:</label></th><td>
-<ul class="errorlist"><li>This field is required.</li></ul>
-<input type="text" name="birthday" id="id_birthday" required></td></tr>""",
+            '<div><label for="id_first_name">First name:</label>'
+            '<ul class="errorlist"><li>This field is required.</li></ul>'
+            '<input type="text" name="first_name" required id="id_first_name"></div>'
+            '<div><label for="id_last_name">Last name:</label>'
+            '<ul class="errorlist"><li>This field is required.</li></ul>'
+            '<input type="text" name="last_name" required id="id_last_name"></div><div>'
+            '<label for="id_birthday">Birthday:</label>'
+            '<ul class="errorlist"><li>This field is required.</li></ul>'
+            '<input type="text" name="birthday" required id="id_birthday"></div>',
         )
         self.assertHTMLEqual(
             p.as_table(),
@@ -261,12 +257,12 @@ class FormsTestCase(SimpleTestCase):
 
         self.assertHTMLEqual(
             str(p),
-            """<tr><th><label for="id_first_name">First name:</label></th><td>
-<input type="text" name="first_name" id="id_first_name" required></td></tr>
-<tr><th><label for="id_last_name">Last name:</label></th><td>
-<input type="text" name="last_name" id="id_last_name" required></td></tr>
-<tr><th><label for="id_birthday">Birthday:</label></th><td>
-<input type="text" name="birthday" id="id_birthday" required></td></tr>""",
+            '<div><label for="id_first_name">First name:</label><input type="text" '
+            'name="first_name" id="id_first_name" required></div><div><label '
+            'for="id_last_name">Last name:</label><input type="text" name="last_name" '
+            'id="id_last_name" required></div><div><label for="id_birthday">'
+            'Birthday:</label><input type="text" name="birthday" id="id_birthday" '
+            "required></div>",
         )
         self.assertHTMLEqual(
             p.as_table(),
@@ -4932,9 +4928,7 @@ class TemplateTests(SimpleTestCase):
 
             t = Template(
                 '<form method="post">'
-                "<table>"
                 "{{ form }}"
-                "</table>"
                 '<input type="submit" required>'
                 "</form>"
             )
@@ -4944,14 +4938,12 @@ class TemplateTests(SimpleTestCase):
         self.assertHTMLEqual(
             my_function("GET", {}),
             '<form method="post">'
-            "<table>"
-            "<tr><th>Username:</th><td>"
-            '<input type="text" name="username" maxlength="10" required></td></tr>'
-            "<tr><th>Password1:</th><td>"
-            '<input type="password" name="password1" required></td></tr>'
-            "<tr><th>Password2:</th><td>"
-            '<input type="password" name="password2" required></td></tr>'
-            "</table>"
+            "<div>Username:"
+            '<input type="text" name="username" maxlength="10" required></div>'
+            "<div>Password1:"
+            '<input type="password" name="password1" required></div>'
+            "<div>Password2:"
+            '<input type="password" name="password2" required></div>'
             '<input type="submit" required>'
             "</form>",
         )
@@ -4966,18 +4958,16 @@ class TemplateTests(SimpleTestCase):
                 },
             ),
             '<form method="post">'
-            "<table>"
-            '<tr><td colspan="2"><ul class="errorlist nonfield">'
-            "<li>Please make sure your passwords match.</li></ul></td></tr>"
-            '<tr><th>Username:</th><td><ul class="errorlist">'
+            '<ul class="errorlist nonfield">'
+            "<li>Please make sure your passwords match.</li></ul>"
+            '<div>Username:<ul class="errorlist">'
             "<li>Ensure this value has at most 10 characters (it has 23).</li></ul>"
             '<input type="text" name="username" '
-            'value="this-is-a-long-username" maxlength="10" required></td></tr>'
-            "<tr><th>Password1:</th><td>"
-            '<input type="password" name="password1" required></td></tr>'
-            "<tr><th>Password2:</th><td>"
-            '<input type="password" name="password2" required></td></tr>'
-            "</table>"
+            'value="this-is-a-long-username" maxlength="10" required></div>'
+            "<div>Password1:"
+            '<input type="password" name="password1" required></div>'
+            "<div>Password2:"
+            '<input type="password" name="password2" required></div>'
             '<input type="submit" required>'
             "</form>",
         )
@@ -5054,7 +5044,7 @@ class OverrideTests(SimpleTestCase):
 
         f = FirstNameForm()
         try:
-            self.assertInHTML("<th>1</th>", f.render())
+            f.render()
         except RecursionError:
             self.fail("Cyclic reference in BoundField.render().")
 
@@ -5069,3 +5059,16 @@ class OverrideTests(SimpleTestCase):
             '<label for="id_name" class="required">Name:</label>'
             '<legend class="required">Language:</legend>',
         )
+
+
+class DeprecationTests(SimpleTestCase):
+    def test_warning(self):
+        from django.forms.utils import DEFAULT_TEMPLATE_DEPRECATION_MSG
+
+        with isolate_lru_cache(get_default_renderer), self.settings(
+            FORM_RENDERER="django.forms.renderers.DjangoTemplates"
+        ), self.assertRaisesMessage(
+            RemovedInDjango50Warning, DEFAULT_TEMPLATE_DEPRECATION_MSG
+        ):
+            form = Person()
+            str(form)

+ 56 - 44
tests/forms_tests/tests/test_formsets.py

@@ -23,10 +23,12 @@ from django.forms.formsets import (
     all_valid,
     formset_factory,
 )
-from django.forms.renderers import TemplatesSetting
+from django.forms.renderers import TemplatesSetting, get_default_renderer
 from django.forms.utils import ErrorList
 from django.forms.widgets import HiddenInput
 from django.test import SimpleTestCase
+from django.test.utils import isolate_lru_cache
+from django.utils.deprecation import RemovedInDjango50Warning
 
 from . import jinja2_tests
 
@@ -125,8 +127,8 @@ class FormsFormsetTestCase(SimpleTestCase):
 <input type="hidden" name="choices-INITIAL_FORMS" value="0">
 <input type="hidden" name="choices-MIN_NUM_FORMS" value="0">
 <input type="hidden" name="choices-MAX_NUM_FORMS" value="1000">
-<tr><th>Choice:</th><td><input type="text" name="choices-0-choice"></td></tr>
-<tr><th>Votes:</th><td><input type="number" name="choices-0-votes"></td></tr>""",
+<div>Choice:<input type="text" name="choices-0-choice"></div>
+<div>Votes:<input type="number" name="choices-0-votes"></div>""",
         )
         # FormSet are treated similarly to Forms. FormSet has an is_valid()
         # method, and a cleaned_data or errors attribute depending on whether
@@ -976,12 +978,12 @@ class FormsFormsetTestCase(SimpleTestCase):
         formset = LimitedFavoriteDrinkFormSet()
         self.assertHTMLEqual(
             "\n".join(str(form) for form in formset.forms),
-            """<tr><th><label for="id_form-0-name">Name:</label></th>
-<td><input type="text" name="form-0-name" id="id_form-0-name"></td></tr>
-<tr><th><label for="id_form-1-name">Name:</label></th>
-<td><input type="text" name="form-1-name" id="id_form-1-name"></td></tr>
-<tr><th><label for="id_form-2-name">Name:</label></th>
-<td><input type="text" name="form-2-name" id="id_form-2-name"></td></tr>""",
+            """<div><label for="id_form-0-name">Name:</label>
+            <input type="text" name="form-0-name" id="id_form-0-name"></div>
+<div><label for="id_form-1-name">Name:</label>
+<input type="text" name="form-1-name" id="id_form-1-name"></div>
+<div><label for="id_form-2-name">Name:</label>
+<input type="text" name="form-2-name" id="id_form-2-name"></div>""",
         )
         # If max_num is 0 then no form is rendered at all.
         LimitedFavoriteDrinkFormSet = formset_factory(
@@ -997,10 +999,10 @@ class FormsFormsetTestCase(SimpleTestCase):
         formset = LimitedFavoriteDrinkFormSet()
         self.assertHTMLEqual(
             "\n".join(str(form) for form in formset.forms),
-            """<tr><th><label for="id_form-0-name">Name:</label></th><td>
-<input type="text" name="form-0-name" id="id_form-0-name"></td></tr>
-<tr><th><label for="id_form-1-name">Name:</label></th>
-<td><input type="text" name="form-1-name" id="id_form-1-name"></td></tr>""",
+            """<div><label for="id_form-0-name">Name:</label>
+<input type="text" name="form-0-name" id="id_form-0-name"></div>
+<div><label for="id_form-1-name">Name:</label>
+<input type="text" name="form-1-name" id="id_form-1-name"></div>""",
         )
 
     def test_limiting_extra_lest_than_max_num(self):
@@ -1011,8 +1013,8 @@ class FormsFormsetTestCase(SimpleTestCase):
         formset = LimitedFavoriteDrinkFormSet()
         self.assertHTMLEqual(
             "\n".join(str(form) for form in formset.forms),
-            """<tr><th><label for="id_form-0-name">Name:</label></th>
-<td><input type="text" name="form-0-name" id="id_form-0-name"></td></tr>""",
+            """<div><label for="id_form-0-name">Name:</label>
+<input type="text" name="form-0-name" id="id_form-0-name"></div>""",
         )
 
     def test_max_num_with_initial_data(self):
@@ -1024,11 +1026,11 @@ class FormsFormsetTestCase(SimpleTestCase):
         self.assertHTMLEqual(
             "\n".join(str(form) for form in formset.forms),
             """
-            <tr><th><label for="id_form-0-name">Name:</label></th>
-            <td><input type="text" name="form-0-name" value="Fernet and Coke"
-                id="id_form-0-name"></td></tr>
-            <tr><th><label for="id_form-1-name">Name:</label></th>
-            <td><input type="text" name="form-1-name" id="id_form-1-name"></td></tr>
+            <div><label for="id_form-0-name">Name:</label>
+            <input type="text" name="form-0-name" value="Fernet and Coke"
+                id="id_form-0-name"></div>
+            <div><label for="id_form-1-name">Name:</label>
+            <input type="text" name="form-1-name" id="id_form-1-name"></div>
             """,
         )
 
@@ -1056,12 +1058,12 @@ class FormsFormsetTestCase(SimpleTestCase):
         self.assertHTMLEqual(
             "\n".join(str(form) for form in formset.forms),
             """
-            <tr><th><label for="id_form-0-name">Name:</label></th>
-            <td><input id="id_form-0-name" name="form-0-name" type="text"
-                value="Fernet and Coke"></td></tr>
-            <tr><th><label for="id_form-1-name">Name:</label></th>
-            <td><input id="id_form-1-name" name="form-1-name" type="text"
-                value="Bloody Mary"></td></tr>
+            <div><label for="id_form-0-name">Name:</label>
+            <input id="id_form-0-name" name="form-0-name" type="text"
+                value="Fernet and Coke"></div>
+            <div><label for="id_form-1-name">Name:</label>
+            <input id="id_form-1-name" name="form-1-name" type="text"
+                value="Bloody Mary"></div>
             """,
         )
 
@@ -1082,18 +1084,15 @@ class FormsFormsetTestCase(SimpleTestCase):
         self.assertHTMLEqual(
             "\n".join(str(form) for form in formset.forms),
             """
-            <tr><th><label for="id_form-0-name">Name:</label></th>
-            <td>
+            <div><label for="id_form-0-name">Name:</label>
             <input id="id_form-0-name" name="form-0-name" type="text" value="Gin Tonic">
-            </td></tr>
-            <tr><th><label for="id_form-1-name">Name:</label></th>
-            <td>
+            </div>
+            <div><label for="id_form-1-name">Name:</label>
             <input id="id_form-1-name" name="form-1-name" type="text"
-                value="Bloody Mary"></td></tr>
-            <tr><th><label for="id_form-2-name">Name:</label></th>
-            <td>
+                value="Bloody Mary"></div>
+            <div><label for="id_form-2-name">Name:</label>
             <input id="id_form-2-name" name="form-2-name" type="text"
-                value="Jack and Coke"></td></tr>
+                value="Jack and Coke"></div>
             """,
         )
 
@@ -1173,12 +1172,11 @@ class FormsFormsetTestCase(SimpleTestCase):
         self.assertHTMLEqual(
             "\n".join(str(form) for form in formset.forms),
             """
-            <tr><th><label for="id_form-0-name">Name:</label></th>
-            <td>
+            <div><label for="id_form-0-name">Name:</label>
             <input type="text" name="form-0-name" value="Gin Tonic" id="id_form-0-name">
-            </td></tr>
-            <tr><th><label for="id_form-1-name">Name:</label></th>
-            <td><input type="text" name="form-1-name" id="id_form-1-name"></td></tr>""",
+            </div>
+            <div><label for="id_form-1-name">Name:</label>
+            <input type="text" name="form-1-name" id="id_form-1-name"></div>""",
         )
 
     def test_management_form_field_names(self):
@@ -1701,16 +1699,16 @@ class TestIsBoundBehavior(SimpleTestCase):
         # Can still render the formset.
         self.assertHTMLEqual(
             str(formset),
-            '<tr><td colspan="2">'
             '<ul class="errorlist nonfield">'
             "<li>(Hidden field TOTAL_FORMS) This field is required.</li>"
             "<li>(Hidden field INITIAL_FORMS) This field is required.</li>"
             "</ul>"
+            "<div>"
             '<input type="hidden" name="form-TOTAL_FORMS" id="id_form-TOTAL_FORMS">'
             '<input type="hidden" name="form-INITIAL_FORMS" id="id_form-INITIAL_FORMS">'
             '<input type="hidden" name="form-MIN_NUM_FORMS" id="id_form-MIN_NUM_FORMS">'
             '<input type="hidden" name="form-MAX_NUM_FORMS" id="id_form-MAX_NUM_FORMS">'
-            "</td></tr>\n",
+            "</div>\n",
         )
 
     def test_management_form_invalid_data(self):
@@ -1732,18 +1730,18 @@ class TestIsBoundBehavior(SimpleTestCase):
         # Can still render the formset.
         self.assertHTMLEqual(
             str(formset),
-            '<tr><td colspan="2">'
             '<ul class="errorlist nonfield">'
             "<li>(Hidden field TOTAL_FORMS) Enter a whole number.</li>"
             "<li>(Hidden field INITIAL_FORMS) Enter a whole number.</li>"
             "</ul>"
+            "<div>"
             '<input type="hidden" name="form-TOTAL_FORMS" value="two" '
             'id="id_form-TOTAL_FORMS">'
             '<input type="hidden" name="form-INITIAL_FORMS" value="one" '
             'id="id_form-INITIAL_FORMS">'
             '<input type="hidden" name="form-MIN_NUM_FORMS" id="id_form-MIN_NUM_FORMS">'
             '<input type="hidden" name="form-MAX_NUM_FORMS" id="id_form-MAX_NUM_FORMS">'
-            "</td></tr>\n",
+            "</div>\n",
         )
 
     def test_customize_management_form_error(self):
@@ -1889,3 +1887,17 @@ class AllValidTests(SimpleTestCase):
         ]
         self.assertEqual(formset1._errors, expected_errors)
         self.assertEqual(formset2._errors, expected_errors)
+
+
+class DeprecationTests(SimpleTestCase):
+    def test_warning(self):
+        from django.forms.utils import DEFAULT_TEMPLATE_DEPRECATION_MSG
+
+        with isolate_lru_cache(get_default_renderer), self.settings(
+            FORM_RENDERER="django.forms.renderers.DjangoTemplates"
+        ), self.assertRaisesMessage(
+            RemovedInDjango50Warning, DEFAULT_TEMPLATE_DEPRECATION_MSG
+        ):
+            ChoiceFormSet = formset_factory(Choice)
+            formset = ChoiceFormSet()
+            str(formset)

+ 59 - 58
tests/model_forms/tests.py

@@ -687,12 +687,12 @@ class ModelFormBaseTest(TestCase):
 
         self.assertHTMLEqual(
             str(SubclassMeta()),
-            """<tr><th><label for="id_name">Name:</label></th>
-<td><input id="id_name" type="text" name="name" maxlength="20" required></td></tr>
-<tr><th><label for="id_slug">Slug:</label></th>
-<td><input id="id_slug" type="text" name="slug" maxlength="20" required></td></tr>
-<tr><th><label for="id_checkbox">Checkbox:</label></th>
-<td><input type="checkbox" name="checkbox" id="id_checkbox" required></td></tr>""",
+            '<div><label for="id_name">Name:</label>'
+            '<input type="text" name="name" maxlength="20" required id="id_name">'
+            '</div><div><label for="id_slug">Slug:</label><input type="text" '
+            'name="slug" maxlength="20" required id="id_slug"></div><div>'
+            '<label for="id_checkbox">Checkbox:</label>'
+            '<input type="checkbox" name="checkbox" required id="id_checkbox"></div>',
         )
 
     def test_orderfields_form(self):
@@ -704,10 +704,10 @@ class ModelFormBaseTest(TestCase):
         self.assertEqual(list(OrderFields.base_fields), ["url", "name"])
         self.assertHTMLEqual(
             str(OrderFields()),
-            """<tr><th><label for="id_url">The URL:</label></th>
-<td><input id="id_url" type="text" name="url" maxlength="40" required></td></tr>
-<tr><th><label for="id_name">Name:</label></th>
-<td><input id="id_name" type="text" name="name" maxlength="20" required></td></tr>""",
+            '<div><label for="id_url">The URL:</label>'
+            '<input type="text" name="url" maxlength="40" required id="id_url">'
+            '</div><div><label for="id_name">Name:</label><input type="text" '
+            'name="name" maxlength="20" required id="id_name"></div>',
         )
 
     def test_orderfields2_form(self):
@@ -1460,12 +1460,11 @@ class ModelFormBasicTests(TestCase):
         f = BaseCategoryForm()
         self.assertHTMLEqual(
             str(f),
-            """<tr><th><label for="id_name">Name:</label></th>
-<td><input id="id_name" type="text" name="name" maxlength="20" required></td></tr>
-<tr><th><label for="id_slug">Slug:</label></th>
-<td><input id="id_slug" type="text" name="slug" maxlength="20" required></td></tr>
-<tr><th><label for="id_url">The URL:</label></th>
-<td><input id="id_url" type="text" name="url" maxlength="40" required></td></tr>""",
+            '<div><label for="id_name">Name:</label><input type="text" name="name" '
+            'maxlength="20" required id="id_name"></div><div><label for="id_slug">Slug:'
+            '</label><input type="text" name="slug" maxlength="20" required '
+            'id="id_slug"></div><div><label for="id_url">The URL:</label>'
+            '<input type="text" name="url" maxlength="40" required id="id_url"></div>',
         )
         self.assertHTMLEqual(
             str(f.as_ul()),
@@ -1538,12 +1537,9 @@ class ModelFormBasicTests(TestCase):
         f = RoykoForm(auto_id=False, instance=self.w_royko)
         self.assertHTMLEqual(
             str(f),
-            """
-            <tr><th>Name:</th><td>
-            <input type="text" name="name" value="Mike Royko" maxlength="50" required>
-            <br>
-            <span class="helptext">Use both first and last names.</span></td></tr>
-            """,
+            '<div>Name:<div class="helptext">Use both first and last names.</div>'
+            '<input type="text" name="name" value="Mike Royko" maxlength="50" '
+            "required></div>",
         )
 
         art = Article.objects.create(
@@ -1703,30 +1699,39 @@ class ModelFormBasicTests(TestCase):
         self.assertHTMLEqual(
             str(f),
             """
-            <tr><th>Headline:</th><td>
-            <input type="text" name="headline" maxlength="50" required></td></tr>
-            <tr><th>Slug:</th><td>
-            <input type="text" name="slug" maxlength="50" required></td></tr>
-            <tr><th>Pub date:</th><td>
-            <input type="text" name="pub_date" required></td></tr>
-            <tr><th>Writer:</th><td><select name="writer" required>
-            <option value="" selected>---------</option>
-            <option value="%s">Bob Woodward</option>
-            <option value="%s">Mike Royko</option>
-            </select></td></tr>
-            <tr><th>Article:</th><td>
-            <textarea rows="10" cols="40" name="article" required></textarea></td></tr>
-            <tr><th>Categories:</th><td><select multiple name="categories">
-            <option value="%s">Entertainment</option>
-            <option value="%s">It&#x27;s a test</option>
-            <option value="%s">Third test</option>
-            </select></td></tr>
-            <tr><th>Status:</th><td><select name="status">
-            <option value="" selected>---------</option>
-            <option value="1">Draft</option>
-            <option value="2">Pending</option>
-            <option value="3">Live</option>
-            </select></td></tr>
+            <div>Headline:
+                <input type="text" name="headline" maxlength="50" required>
+            </div>
+            <div>Slug:
+                <input type="text" name="slug" maxlength="50" required>
+            </div>
+            <div>Pub date:
+                <input type="text" name="pub_date" required>
+            </div>
+            <div>Writer:
+                <select name="writer" required>
+                    <option value="" selected>---------</option>
+                    <option value="%s">Bob Woodward</option>
+                    <option value="%s">Mike Royko</option>
+                </select>
+            </div>
+            <div>Article:
+                <textarea name="article" cols="40" rows="10" required></textarea>
+            </div>
+            <div>Categories:
+                <select name="categories" multiple>
+                    <option value="%s">Entertainment</option>
+                    <option value="%s">It&#x27;s a test</option>
+                    <option value="%s">Third test</option>
+                </select>
+            </div>
+            <div>Status:
+                <select name="status">
+                    <option value="" selected>---------</option>
+                    <option value="1">Draft</option><option value="2">Pending</option>
+                    <option value="3">Live</option>
+                </select>
+            </div>
             """
             % (self.w_woodward.pk, self.w_royko.pk, self.c1.pk, self.c2.pk, self.c3.pk),
         )
@@ -1791,12 +1796,8 @@ class ModelFormBasicTests(TestCase):
         f = PartialArticleForm(auto_id=False)
         self.assertHTMLEqual(
             str(f),
-            """
-            <tr><th>Headline:</th><td>
-            <input type="text" name="headline" maxlength="50" required></td></tr>
-            <tr><th>Pub date:</th><td>
-            <input type="text" name="pub_date" required></td></tr>
-            """,
+            '<div>Headline:<input type="text" name="headline" maxlength="50" required>'
+            '</div><div>Pub date:<input type="text" name="pub_date" required></div>',
         )
 
         class PartialArticleFormWithSlug(forms.ModelForm):
@@ -2990,10 +2991,10 @@ class OtherModelFormTests(TestCase):
 
         self.assertHTMLEqual(
             str(CategoryForm()),
-            """<tr><th><label for="id_description">Description:</label></th>
-<td><input type="text" name="description" id="id_description" required></td></tr>
-<tr><th><label for="id_url">The URL:</label></th>
-<td><input id="id_url" type="text" name="url" maxlength="40" required></td></tr>""",
+            '<div><label for="id_description">Description:</label><input type="text" '
+            'name="description" required id="id_description"></div><div>'
+            '<label for="id_url">The URL:</label><input type="text" name="url" '
+            'maxlength="40" required id="id_url"></div>',
         )
         # to_field_name should also work on ModelMultipleChoiceField ##################
 
@@ -3014,8 +3015,8 @@ class OtherModelFormTests(TestCase):
         self.assertEqual(list(CustomFieldForExclusionForm.base_fields), ["name"])
         self.assertHTMLEqual(
             str(CustomFieldForExclusionForm()),
-            """<tr><th><label for="id_name">Name:</label></th>
-<td><input id="id_name" type="text" name="name" maxlength="10" required></td></tr>""",
+            '<div><label for="id_name">Name:</label><input type="text" '
+            'name="name" maxlength="10" required id="id_name"></div>',
         )
 
     def test_iterable_model_m2m(self):

+ 6 - 8
tests/postgres_tests/test_array.py

@@ -1196,14 +1196,12 @@ class TestSplitFormField(PostgreSQLSimpleTestCase):
         self.assertHTMLEqual(
             str(SplitForm()),
             """
-            <tr>
-                <th><label for="id_array_0">Array:</label></th>
-                <td>
-                    <input id="id_array_0" name="array_0" type="text" required>
-                    <input id="id_array_1" name="array_1" type="text" required>
-                    <input id="id_array_2" name="array_2" type="text" required>
-                </td>
-            </tr>
+            <div>
+                <label for="id_array_0">Array:</label>
+                <input id="id_array_0" name="array_0" type="text" required>
+                <input id="id_array_1" name="array_1" type="text" required>
+                <input id="id_array_2" name="array_2" type="text" required>
+            </div>
         """,
         )
 

+ 10 - 12
tests/postgres_tests/test_ranges.py

@@ -687,17 +687,15 @@ class TestFormField(PostgreSQLSimpleTestCase):
         self.assertHTMLEqual(
             str(form),
             """
-            <tr>
-                <th>
-                <label>Field:</label>
-                </th>
-                <td>
+            <div>
+                <fieldset>
+                    <legend>Field:</legend>
                     <input id="id_field_0_0" name="field_0_0" type="text">
                     <input id="id_field_0_1" name="field_0_1" type="text">
                     <input id="id_field_1_0" name="field_1_0" type="text">
                     <input id="id_field_1_1" name="field_1_1" type="text">
-                </td>
-            </tr>
+                </fieldset>
+            </div>
         """,
         )
         form = SplitForm(
@@ -788,13 +786,13 @@ class TestFormField(PostgreSQLSimpleTestCase):
         self.assertHTMLEqual(
             str(RangeForm()),
             """
-        <tr>
-            <th><label>Ints:</label></th>
-            <td>
+        <div>
+            <fieldset>
+                <legend>Ints:</legend>
                 <input id="id_ints_0" name="ints_0" type="number">
                 <input id="id_ints_1" name="ints_1" type="number">
-            </td>
-        </tr>
+            </fieldset>
+        </div>
         """,
         )
 

+ 3 - 0
tests/runtests.py

@@ -243,6 +243,9 @@ def setup_collect_tests(start_at, start_after, test_labels=None):
         "fields.W342",  # ForeignKey(unique=True) -> OneToOneField
     ]
 
+    # RemovedInDjango50Warning
+    settings.FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
+
     # Load all the ALWAYS_INSTALLED_APPS.
     django.setup()