diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index ca5f2724db..94e6880481 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -9,7 +9,7 @@ from collections import defaultdict
from graphlib import CycleError, TopologicalSorter
from itertools import chain
-from django.forms.utils import to_current_timezone
+from django.forms.utils import flatatt, to_current_timezone
from django.templatetags.static import static
from django.utils import formats
from django.utils.choices import normalize_choices
@@ -23,6 +23,7 @@ from django.utils.translation import gettext_lazy as _
from .renderers import get_default_renderer
__all__ = (
+ "Script",
"Media",
"MediaDefiningClass",
"Widget",
@@ -61,6 +62,53 @@ class MediaOrderConflictWarning(RuntimeWarning):
pass
+@html_safe
+class MediaAsset:
+ element_template = "{path}"
+
+ def __init__(self, path, **attributes):
+ self._path = path
+ self.attributes = attributes
+
+ def __eq__(self, other):
+ # Compare the path only, to ensure performant comparison in Media.merge.
+ return (self.__class__ is other.__class__ and self.path == other.path) or (
+ isinstance(other, str) and self._path == other
+ )
+
+ def __hash__(self):
+ # Hash the path only, to ensure performant comparison in Media.merge.
+ return hash(self._path)
+
+ def __str__(self):
+ return format_html(
+ self.element_template,
+ path=self.path,
+ attributes=flatatt(self.attributes),
+ )
+
+ def __repr__(self):
+ return f"{type(self).__qualname__}({self._path!r})"
+
+ @property
+ def path(self):
+ """
+ Ensure an absolute path.
+ Relative paths are resolved via the {% static %} template tag.
+ """
+ if self._path.startswith(("http://", "https://", "/")):
+ return self._path
+ return static(self._path)
+
+
+class Script(MediaAsset):
+ element_template = ''
+
+ def __init__(self, src, **attributes):
+ # We alter the signature to allow src to be passed as a keyword argument.
+ super().__init__(src, **attributes)
+
+
@html_safe
class Media:
def __init__(self, media=None, css=None, js=None):
diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt
index aaf47ff8e8..de99832f13 100644
--- a/docs/releases/5.2.txt
+++ b/docs/releases/5.2.txt
@@ -259,6 +259,10 @@ Forms
* An :attr:`~django.forms.BoundField.aria_describedby` property is added to
``BoundField`` to ease use of this HTML attribute in templates.
+* The new asset object :class:`~django.forms.Script` is available for adding
+ custom HTML-attributes to JavaScript in form media. See
+ :ref:`paths as objects ` for more details.
+
Generic Views
~~~~~~~~~~~~~
diff --git a/docs/topics/forms/media.txt b/docs/topics/forms/media.txt
index 3fddf2d4bb..17bd385ed0 100644
--- a/docs/topics/forms/media.txt
+++ b/docs/topics/forms/media.txt
@@ -2,6 +2,8 @@
Form Assets (the ``Media`` class)
=================================
+.. currentmodule:: django.forms
+
Rendering an attractive and easy-to-use web form requires more than just
HTML - it also requires CSS stylesheets, and if you want to use fancy widgets,
you may also need to include some JavaScript on each page. The exact
@@ -130,6 +132,24 @@ A tuple describing the required JavaScript files. See :ref:`the
section on paths ` for details of how to specify
paths to these files.
+``Script`` objects
+~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 5.2
+
+.. class:: Script(src, **attributes)
+
+ Represents a script file.
+
+ The first parameter, ``src``, is the string path to the script file. See
+ :ref:`the section on paths ` for details on how to
+ specify paths to these files.
+
+ The optional keyword arguments, ``**attributes``, are HTML attributes that
+ are set on the rendered ``
+.. _form-media-asset-objects:
+
Paths as objects
----------------
-Asset paths may also be given as hashable objects implementing an
-``__html__()`` method. The ``__html__()`` method is typically added using the
-:func:`~django.utils.html.html_safe` decorator. The object is responsible for
-outputting the complete HTML ``
+
+.. versionchanged:: 5.2
+
+ The object class ``Script`` was added.
``Media`` objects
=================
diff --git a/tests/forms_tests/tests/test_media.py b/tests/forms_tests/tests/test_media.py
index 061adc09ca..032deedf0d 100644
--- a/tests/forms_tests/tests/test_media.py
+++ b/tests/forms_tests/tests/test_media.py
@@ -1,8 +1,96 @@
from django.forms import CharField, Form, Media, MultiWidget, TextInput
+from django.forms.widgets import MediaAsset, Script
from django.template import Context, Template
-from django.templatetags.static import static
from django.test import SimpleTestCase, override_settings
-from django.utils.html import format_html, html_safe
+from django.utils.html import html_safe
+
+
+class CSS(MediaAsset):
+ element_template = ''
+
+ def __init__(self, href, **attributes):
+ super().__init__(href, **attributes)
+ self.attributes["rel"] = "stylesheet"
+
+
+@override_settings(
+ STATIC_URL="http://media.example.com/static/",
+)
+class MediaAssetTestCase(SimpleTestCase):
+ def test_init(self):
+ attributes = {"media": "all", "is": "magic-css"}
+ asset = MediaAsset("path/to/css", **attributes)
+ self.assertEqual(asset._path, "path/to/css")
+ self.assertEqual(asset.attributes, attributes)
+ self.assertIsNot(asset.attributes, attributes)
+
+ def test_eq(self):
+ self.assertEqual(MediaAsset("path/to/css"), MediaAsset("path/to/css"))
+ self.assertEqual(MediaAsset("path/to/css"), "path/to/css")
+ self.assertEqual(
+ MediaAsset("path/to/css", media="all"), MediaAsset("path/to/css")
+ )
+
+ self.assertNotEqual(MediaAsset("path/to/css"), MediaAsset("path/to/other.css"))
+ self.assertNotEqual(MediaAsset("path/to/css"), "path/to/other.css")
+ self.assertNotEqual(MediaAsset("path/to/css", media="all"), CSS("path/to/css"))
+
+ def test_hash(self):
+ self.assertEqual(hash(MediaAsset("path/to/css")), hash("path/to/css"))
+ self.assertEqual(
+ hash(MediaAsset("path/to/css")), hash(MediaAsset("path/to/css"))
+ )
+
+ def test_str(self):
+ self.assertEqual(
+ str(MediaAsset("path/to/css")),
+ "http://media.example.com/static/path/to/css",
+ )
+ self.assertEqual(
+ str(MediaAsset("http://media.other.com/path/to/css")),
+ "http://media.other.com/path/to/css",
+ )
+
+ def test_repr(self):
+ self.assertEqual(repr(MediaAsset("path/to/css")), "MediaAsset('path/to/css')")
+ self.assertEqual(
+ repr(MediaAsset("http://media.other.com/path/to/css")),
+ "MediaAsset('http://media.other.com/path/to/css')",
+ )
+
+ def test_path(self):
+ asset = MediaAsset("path/to/css")
+ self.assertEqual(asset.path, "http://media.example.com/static/path/to/css")
+
+ asset = MediaAsset("http://media.other.com/path/to/css")
+ self.assertEqual(asset.path, "http://media.other.com/path/to/css")
+
+ asset = MediaAsset("https://secure.other.com/path/to/css")
+ self.assertEqual(asset.path, "https://secure.other.com/path/to/css")
+
+ asset = MediaAsset("/absolute/path/to/css")
+ self.assertEqual(asset.path, "/absolute/path/to/css")
+
+ asset = MediaAsset("//absolute/path/to/css")
+ self.assertEqual(asset.path, "//absolute/path/to/css")
+
+
+@override_settings(STATIC_URL="http://media.example.com/static/")
+class ScriptTestCase(SimpleTestCase):
+ def test_init_with_src_kwarg(self):
+ self.assertEqual(
+ Script(src="path/to/js").path, "http://media.example.com/static/path/to/js"
+ )
+
+ def test_str(self):
+ self.assertHTMLEqual(
+ str(Script("path/to/js")),
+ '',
+ )
+ self.assertHTMLEqual(
+ str(Script("path/to/js", **{"async": True, "deferred": False})),
+ '',
+ )
@override_settings(
@@ -714,63 +802,6 @@ class FormsMediaTestCase(SimpleTestCase):
self.assertEqual(merged._js_lists, [["a"]])
-@html_safe
-class Asset:
- def __init__(self, path):
- self.path = path
-
- def __eq__(self, other):
- return (self.__class__ == other.__class__ and self.path == other.path) or (
- other.__class__ == str and self.path == other
- )
-
- def __hash__(self):
- return hash(self.path)
-
- def __str__(self):
- return self.absolute_path(self.path)
-
- def absolute_path(self, path):
- """
- Given a relative or absolute path to a static asset, return an absolute
- path. An absolute path will be returned unchanged while a relative path
- will be passed to django.templatetags.static.static().
- """
- if path.startswith(("http://", "https://", "/")):
- return path
- return static(path)
-
- def __repr__(self):
- return f"{self.path!r}"
-
-
-class CSS(Asset):
- def __init__(self, path, medium):
- super().__init__(path)
- self.medium = medium
-
- def __str__(self):
- path = super().__str__()
- return format_html(
- '',
- self.absolute_path(path),
- self.medium,
- )
-
-
-class JS(Asset):
- def __init__(self, path, integrity=None):
- super().__init__(path)
- self.integrity = integrity or ""
-
- def __str__(self, integrity=None):
- path = super().__str__()
- template = '' % (
- ' integrity="{}"' if self.integrity else "{}"
- )
- return format_html(template, self.absolute_path(path), self.integrity)
-
-
@override_settings(
STATIC_URL="http://media.example.com/static/",
)
@@ -779,17 +810,22 @@ class FormsMediaObjectTestCase(SimpleTestCase):
def test_construction(self):
m = Media(
- css={"all": (CSS("path/to/css1", "all"), CSS("/path/to/css2", "all"))},
+ css={
+ "all": (
+ CSS("path/to/css1", media="all"),
+ CSS("/path/to/css2", media="all"),
+ )
+ },
js=(
- JS("/path/to/js1"),
- JS("http://media.other.com/path/to/js2"),
- JS(
+ Script("/path/to/js1"),
+ Script("http://media.other.com/path/to/js2"),
+ Script(
"https://secure.other.com/path/to/js3",
integrity="9d947b87fdeb25030d56d01f7aa75800",
),
),
)
- self.assertEqual(
+ self.assertHTMLEqual(
str(m),
'\n'
@@ -801,9 +837,9 @@ class FormsMediaObjectTestCase(SimpleTestCase):
)
self.assertEqual(
repr(m),
- "Media(css={'all': ['path/to/css1', '/path/to/css2']}, "
- "js=['/path/to/js1', 'http://media.other.com/path/to/js2', "
- "'https://secure.other.com/path/to/js3'])",
+ "Media(css={'all': [CSS('path/to/css1'), CSS('/path/to/css2')]}, "
+ "js=[Script('/path/to/js1'), Script('http://media.other.com/path/to/js2'), "
+ "Script('https://secure.other.com/path/to/js3')])",
)
def test_simplest_class(self):
@@ -823,22 +859,24 @@ class FormsMediaObjectTestCase(SimpleTestCase):
def test_combine_media(self):
class MyWidget1(TextInput):
class Media:
- css = {"all": (CSS("path/to/css1", "all"), "/path/to/css2")}
+ css = {"all": (CSS("path/to/css1", media="all"), "/path/to/css2")}
js = (
"/path/to/js1",
"http://media.other.com/path/to/js2",
"https://secure.other.com/path/to/js3",
- JS("/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"),
+ Script(
+ "/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"
+ ),
)
class MyWidget2(TextInput):
class Media:
- css = {"all": (CSS("/path/to/css2", "all"), "/path/to/css3")}
- js = (JS("/path/to/js1"), "/path/to/js4")
+ css = {"all": (CSS("/path/to/css2", media="all"), "/path/to/css3")}
+ js = (Script("/path/to/js1"), "/path/to/js4")
w1 = MyWidget1()
w2 = MyWidget2()
- self.assertEqual(
+ self.assertHTMLEqual(
str(w1.media + w2.media),
'\n'
@@ -857,14 +895,14 @@ class FormsMediaObjectTestCase(SimpleTestCase):
media = Media(
css={
"all": (
- CSS("/path/to/css1", "all"),
- CSS("/path/to/css1", "all"),
+ CSS("/path/to/css1", media="all"),
+ CSS("/path/to/css1", media="all"),
"/path/to/css1",
)
},
- js=(JS("/path/to/js1"), JS("/path/to/js1"), "/path/to/js1"),
+ js=(Script("/path/to/js1"), Script("/path/to/js1"), "/path/to/js1"),
)
- self.assertEqual(
+ self.assertHTMLEqual(
str(media),
'\n'
'',