Skip to content

Commit c92a9a8

Browse files
committed
Add attributes to Dash Resources added to html tags
1 parent 734e241 commit c92a9a8

File tree

4 files changed

+277
-21
lines changed

4 files changed

+277
-21
lines changed

dash/dash.py

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,7 +1015,10 @@ def get_dist(self, libraries: Sequence[str]) -> list:
10151015
dists.append(dict(type=dist_type, url=src))
10161016
return dists
10171017

1018-
def _collect_and_register_resources(self, resources, include_async=True):
1018+
# pylint: disable=too-many-branches
1019+
def _collect_and_register_resources(
1020+
self, resources, include_async=True, url_attr="src"
1021+
):
10191022
# now needs the app context.
10201023
# template in the necessary component suite JS bundles
10211024
# add the version number of the package as a query parameter
@@ -1059,35 +1062,44 @@ def _relative_url_path(relative_package_path="", namespace=""):
10591062
self.registered_paths[resource["namespace"]].add(rel_path)
10601063

10611064
if not is_dynamic_resource and not excluded:
1062-
srcs.append(
1063-
_relative_url_path(
1064-
relative_package_path=rel_path,
1065-
namespace=resource["namespace"],
1066-
)
1065+
url = _relative_url_path(
1066+
relative_package_path=rel_path,
1067+
namespace=resource["namespace"],
10671068
)
1069+
if "attributes" in resource:
1070+
srcs.append({url_attr: url, **resource["attributes"]})
1071+
else:
1072+
srcs.append(url)
10681073
elif "external_url" in resource:
10691074
if not is_dynamic_resource and not excluded:
1070-
if isinstance(resource["external_url"], str):
1071-
srcs.append(resource["external_url"])
1072-
else:
1073-
srcs += resource["external_url"]
1075+
urls = (
1076+
[resource["external_url"]]
1077+
if isinstance(resource["external_url"], str)
1078+
else resource["external_url"]
1079+
)
1080+
for url in urls:
1081+
if "attributes" in resource:
1082+
srcs.append({url_attr: url, **resource["attributes"]})
1083+
else:
1084+
srcs.append(url)
10741085
elif "absolute_path" in resource:
10751086
raise Exception("Serving files from absolute_path isn't supported yet")
10761087
elif "asset_path" in resource:
10771088
static_url = self.get_asset_url(resource["asset_path"])
1089+
url_with_cache = static_url + f"?m={resource['ts']}"
10781090
# Import .mjs files with type=module script tag
10791091
if static_url.endswith(".mjs"):
1080-
srcs.append(
1081-
{
1082-
"src": static_url
1083-
+ f"?m={resource['ts']}", # Add a cache-busting query param
1084-
"type": "module",
1085-
}
1086-
)
1092+
attrs = {url_attr: url_with_cache, "type": "module"}
1093+
if "attributes" in resource:
1094+
attrs.update(resource["attributes"])
1095+
srcs.append(attrs)
10871096
else:
1088-
srcs.append(
1089-
static_url + f"?m={resource['ts']}"
1090-
) # Add a cache-busting query param
1097+
if "attributes" in resource:
1098+
srcs.append(
1099+
{url_attr: url_with_cache, **resource["attributes"]}
1100+
)
1101+
else:
1102+
srcs.append(url_with_cache)
10911103

10921104
return srcs
10931105

@@ -1096,7 +1108,8 @@ def _generate_css_dist_html(self):
10961108
external_links = self.config.external_stylesheets
10971109
links = self._collect_and_register_resources(
10981110
self.css.get_all_css()
1099-
+ self.css._resources._filter_resources(self._hooks.hooks._css_dist)
1111+
+ self.css._resources._filter_resources(self._hooks.hooks._css_dist),
1112+
url_attr="href",
11001113
)
11011114

11021115
return "\n".join(

dash/resources.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"external_only": bool,
2626
"filepath": str,
2727
"dev_only": bool,
28+
"attributes": _t.Dict[str, str],
2829
},
2930
total=False,
3031
)
@@ -80,6 +81,8 @@ def _filter_resources(
8081
)
8182
if "namespace" in s:
8283
filtered_resource["namespace"] = s["namespace"]
84+
if "attributes" in s:
85+
filtered_resource["attributes"] = s["attributes"]
8386

8487
if "external_url" in s and (
8588
s.get("external_only") or not self.config.serve_locally

tests/integration/dash_assets/test_dash_assets.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,117 @@ def test_dada002_external_files_init(dash_duo):
120120

121121
# ensure ramda was loaded before the assets so they can use it.
122122
assert dash_duo.find_element("#ramda-test").text == "Hello World"
123+
124+
125+
def test_dada003_external_resources_with_attributes(dash_duo):
126+
"""Test that attributes field works for external scripts and stylesheets"""
127+
app = Dash(__name__)
128+
129+
# Test scripts with type="module" and other attributes
130+
app.scripts.append_script(
131+
{
132+
"external_url": "https://cdn.example.com/module-script.js",
133+
"attributes": {"type": "module"},
134+
}
135+
)
136+
137+
app.scripts.append_script(
138+
{
139+
"external_url": "https://cdn.example.com/async-script.js",
140+
"attributes": {"async": "true", "data-test": "custom"},
141+
}
142+
)
143+
144+
# Test CSS with custom attributes
145+
app.css.append_css(
146+
{
147+
"external_url": "https://cdn.example.com/print-styles.css",
148+
"attributes": {"media": "print"},
149+
}
150+
)
151+
152+
app.layout = html.Div("Test Layout", id="content")
153+
154+
dash_duo.start_server(app)
155+
156+
# Verify script with type="module" is rendered correctly
157+
module_script = dash_duo.find_element(
158+
"//script[@src='https://cdn.example.com/module-script.js' and @type='module']",
159+
attribute="XPATH",
160+
)
161+
assert (
162+
module_script is not None
163+
), "Module script should be present with type='module'"
164+
165+
# Verify script with async and custom data attribute
166+
async_script = dash_duo.find_element(
167+
"//script[@src='https://cdn.example.com/async-script.js' and @async='true' and @data-test='custom']",
168+
attribute="XPATH",
169+
)
170+
assert (
171+
async_script is not None
172+
), "Async script should be present with custom attributes"
173+
174+
# Verify CSS with media attribute
175+
print_css = dash_duo.find_element(
176+
"//link[@href='https://cdn.example.com/print-styles.css' and @media='print']",
177+
attribute="XPATH",
178+
)
179+
assert print_css is not None, "Print CSS should be present with media='print'"
180+
181+
182+
def test_dada004_external_scripts_init_with_attributes(dash_duo):
183+
"""Test that attributes work when passed via external_scripts in Dash constructor"""
184+
js_files = [
185+
"https://cdn.example.com/regular-script.js",
186+
{"src": "https://cdn.example.com/es-module.js", "type": "module"},
187+
{
188+
"src": "https://cdn.example.com/integrity-script.js",
189+
"integrity": "sha256-test123",
190+
"crossorigin": "anonymous",
191+
},
192+
]
193+
194+
css_files = [
195+
"https://cdn.example.com/regular-styles.css",
196+
{
197+
"href": "https://cdn.example.com/dark-theme.css",
198+
"media": "(prefers-color-scheme: dark)",
199+
},
200+
]
201+
202+
app = Dash(__name__, external_scripts=js_files, external_stylesheets=css_files)
203+
app.layout = html.Div("Test", id="content")
204+
205+
dash_duo.start_server(app)
206+
207+
# Verify regular script (string format)
208+
dash_duo.find_element(
209+
"//script[@src='https://cdn.example.com/regular-script.js']", attribute="XPATH"
210+
)
211+
212+
# Verify ES module script
213+
module_script = dash_duo.find_element(
214+
"//script[@src='https://cdn.example.com/es-module.js' and @type='module']",
215+
attribute="XPATH",
216+
)
217+
assert module_script is not None
218+
219+
# Verify script with integrity and crossorigin
220+
integrity_script = dash_duo.find_element(
221+
"//script[@src='https://cdn.example.com/integrity-script.js' and @integrity='sha256-test123' and @crossorigin='anonymous']",
222+
attribute="XPATH",
223+
)
224+
assert integrity_script is not None
225+
226+
# Verify regular CSS
227+
dash_duo.find_element(
228+
"//link[@href='https://cdn.example.com/regular-styles.css']", attribute="XPATH"
229+
)
230+
231+
# Verify CSS with media query
232+
dark_css = dash_duo.find_element(
233+
"//link[@href='https://cdn.example.com/dark-theme.css' and @media='(prefers-color-scheme: dark)']",
234+
attribute="XPATH",
235+
)
236+
assert dark_css is not None

tests/unit/test_resources.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,129 @@ def test_collect_and_register_resources(mocker):
122122
]
123123
)
124124
import_mock.assert_any_call("dash_html_components")
125+
126+
127+
def test_resources_with_attributes():
128+
"""Test that attributes are passed through in external_url resources"""
129+
app = dash.Dash(__name__, serve_locally=False)
130+
131+
# Test external scripts with attributes
132+
resources = app._collect_and_register_resources(
133+
[
134+
{
135+
"external_url": "https://example.com/module.js",
136+
"attributes": {"type": "module"},
137+
},
138+
{
139+
"external_url": "https://example.com/script.js",
140+
"attributes": {
141+
"crossorigin": "anonymous",
142+
"integrity": "sha256-abc123",
143+
},
144+
},
145+
]
146+
)
147+
148+
assert resources == [
149+
{"src": "https://example.com/module.js", "type": "module"},
150+
{
151+
"src": "https://example.com/script.js",
152+
"crossorigin": "anonymous",
153+
"integrity": "sha256-abc123",
154+
},
155+
]
156+
157+
158+
def test_css_resources_with_attributes():
159+
"""Test that attributes are passed through in CSS resources with href"""
160+
app = dash.Dash(__name__, serve_locally=False)
161+
162+
# Test external CSS with attributes
163+
resources = app._collect_and_register_resources(
164+
[
165+
{
166+
"external_url": "https://example.com/styles.css",
167+
"attributes": {"media": "print"},
168+
},
169+
{
170+
"external_url": "https://example.com/theme.css",
171+
"attributes": {"crossorigin": "anonymous"},
172+
},
173+
],
174+
url_attr="href",
175+
)
176+
177+
assert resources == [
178+
{"href": "https://example.com/styles.css", "media": "print"},
179+
{"href": "https://example.com/theme.css", "crossorigin": "anonymous"},
180+
]
181+
182+
183+
def test_resources_without_attributes():
184+
"""Test that resources without attributes still work as strings"""
185+
app = dash.Dash(__name__, serve_locally=False)
186+
187+
resources = app._collect_and_register_resources(
188+
[
189+
{"external_url": "https://example.com/script.js"},
190+
]
191+
)
192+
193+
assert resources == ["https://example.com/script.js"]
194+
195+
196+
def test_local_resources_with_attributes(mocker):
197+
"""Test that attributes work with local resources"""
198+
mocker.patch("dash.dcc._js_dist")
199+
mocker.patch("dash.dcc.__version__")
200+
dcc._js_dist = [
201+
{
202+
"relative_package_path": "module.js",
203+
"namespace": "dash_core_components",
204+
"attributes": {"type": "module"},
205+
}
206+
]
207+
dcc.__version__ = "1.0.0"
208+
209+
app = dash.Dash(__name__)
210+
app.layout = dcc.Markdown()
211+
212+
with mock.patch("dash.dash.os.stat", return_value=StatMock()):
213+
with mock.patch("dash.dash.importlib.import_module", return_value=dcc):
214+
with mock.patch("sys.modules", {"dash_core_components": dcc}):
215+
resources = app._collect_and_register_resources(
216+
[
217+
{
218+
"relative_package_path": "module.js",
219+
"namespace": "dash_core_components",
220+
"attributes": {"type": "module"},
221+
}
222+
]
223+
)
224+
225+
assert len(resources) == 1
226+
assert isinstance(resources[0], dict)
227+
assert "src" in resources[0]
228+
assert resources[0]["type"] == "module"
229+
230+
231+
def test_multiple_external_urls_with_attributes():
232+
"""Test that multiple external URLs with attributes work correctly"""
233+
app = dash.Dash(__name__, serve_locally=False)
234+
235+
resources = app._collect_and_register_resources(
236+
[
237+
{
238+
"external_url": [
239+
"https://example.com/script1.js",
240+
"https://example.com/script2.js",
241+
],
242+
"attributes": {"type": "module"},
243+
}
244+
]
245+
)
246+
247+
assert resources == [
248+
{"src": "https://example.com/script1.js", "type": "module"},
249+
{"src": "https://example.com/script2.js", "type": "module"},
250+
]

0 commit comments

Comments
 (0)