Skip to content

Commit b4d9837

Browse files
authored
Merge pull request #3541 from plotly/resources-attributes
Resources attributes
2 parents 734e241 + a9d4bbb commit b4d9837

File tree

7 files changed

+305
-24
lines changed

7 files changed

+305
-24
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
All notable changes to `dash` will be documented in this file.
33
This project adheres to [Semantic Versioning](https://semver.org/).
44

5+
## [UNRELEASED]
6+
7+
## Added
8+
- [#3541](https://github.com/plotly/dash/pull/3541) Add `attributes` dictionary to be be formatted on script/link (_js_dist/_css_dist) tags of the index, allows for `type="module"` or `type="importmap"`. [#3538](https://github.com/plotly/dash/issues/3538)
9+
10+
## Fixed
11+
- [#3541](https://github.com/plotly/dash/pull/3541) Remove last reference of deprecated `pkg_resources`.
12+
513
## [3.3.0] - 2025-11-12
614

715
## Added

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/development/component_generator.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import argparse
99
import shutil
1010
import functools
11-
import pkg_resources
11+
import importlib.resources as importlib_resources
12+
1213
import yaml
1314

1415
from ._r_components_generation import write_class_file
@@ -57,7 +58,16 @@ def generate_components(
5758

5859
is_windows = sys.platform == "win32"
5960

60-
extract_path = pkg_resources.resource_filename("dash", "extract-meta.js")
61+
# Get path to extract-meta.js using importlib.resources
62+
try:
63+
# Python 3.9+
64+
extract_path = str(
65+
importlib_resources.files("dash").joinpath("extract-meta.js")
66+
)
67+
except AttributeError:
68+
# Python 3.8 fallback
69+
with importlib_resources.path("dash", "extract-meta.js") as p:
70+
extract_path = str(p)
6171

6272
reserved_patterns = "|".join(f"^{p}$" for p in reserved_words)
6373

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

requirements/ci.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ pandas>=1.4.0
1313
pyarrow
1414
pylint==3.0.3
1515
pytest-mock
16-
pytest-sugar==0.9.6
1716
pyzmq>=26.0.0
1817
xlrd>=2.0.1
1918
pytest-rerunfailures

tests/integration/dash_assets/test_dash_assets.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,120 @@ 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+
"external_only": True,
135+
}
136+
)
137+
138+
app.scripts.append_script(
139+
{
140+
"external_url": "https://cdn.example.com/async-script.js",
141+
"attributes": {"async": "true", "data-test": "custom"},
142+
"external_only": True,
143+
}
144+
)
145+
146+
# Test CSS with custom attributes
147+
app.css.append_css(
148+
{
149+
"external_url": "https://cdn.example.com/print-styles.css",
150+
"attributes": {"media": "print"},
151+
"external_only": True,
152+
}
153+
)
154+
155+
app.layout = html.Div("Test Layout", id="content")
156+
157+
dash_duo.start_server(app)
158+
159+
# Verify script with type="module" is rendered correctly
160+
module_script = dash_duo.find_element(
161+
"//script[@src='https://cdn.example.com/module-script.js' and @type='module']",
162+
attribute="XPATH",
163+
)
164+
assert (
165+
module_script is not None
166+
), "Module script should be present with type='module'"
167+
168+
# Verify script with async and custom data attribute
169+
async_script = dash_duo.find_element(
170+
"//script[@src='https://cdn.example.com/async-script.js' and @async='true' and @data-test='custom']",
171+
attribute="XPATH",
172+
)
173+
assert (
174+
async_script is not None
175+
), "Async script should be present with custom attributes"
176+
177+
# Verify CSS with media attribute
178+
print_css = dash_duo.find_element(
179+
"//link[@href='https://cdn.example.com/print-styles.css' and @media='print']",
180+
attribute="XPATH",
181+
)
182+
assert print_css is not None, "Print CSS should be present with media='print'"
183+
184+
185+
def test_dada004_external_scripts_init_with_attributes(dash_duo):
186+
"""Test that attributes work when passed via external_scripts in Dash constructor"""
187+
js_files = [
188+
"https://cdn.example.com/regular-script.js",
189+
{"src": "https://cdn.example.com/es-module.js", "type": "module"},
190+
{
191+
"src": "https://cdn.example.com/integrity-script.js",
192+
"integrity": "sha256-test123",
193+
"crossorigin": "anonymous",
194+
},
195+
]
196+
197+
css_files = [
198+
"https://cdn.example.com/regular-styles.css",
199+
{
200+
"href": "https://cdn.example.com/dark-theme.css",
201+
"media": "(prefers-color-scheme: dark)",
202+
},
203+
]
204+
205+
app = Dash(__name__, external_scripts=js_files, external_stylesheets=css_files)
206+
app.layout = html.Div("Test", id="content")
207+
208+
dash_duo.start_server(app)
209+
210+
# Verify regular script (string format)
211+
dash_duo.find_element(
212+
"//script[@src='https://cdn.example.com/regular-script.js']", attribute="XPATH"
213+
)
214+
215+
# Verify ES module script
216+
module_script = dash_duo.find_element(
217+
"//script[@src='https://cdn.example.com/es-module.js' and @type='module']",
218+
attribute="XPATH",
219+
)
220+
assert module_script is not None
221+
222+
# Verify script with integrity and crossorigin
223+
integrity_script = dash_duo.find_element(
224+
"//script[@src='https://cdn.example.com/integrity-script.js' and @integrity='sha256-test123' and @crossorigin='anonymous']",
225+
attribute="XPATH",
226+
)
227+
assert integrity_script is not None
228+
229+
# Verify regular CSS
230+
dash_duo.find_element(
231+
"//link[@href='https://cdn.example.com/regular-styles.css']", attribute="XPATH"
232+
)
233+
234+
# Verify CSS with media query
235+
dark_css = dash_duo.find_element(
236+
"//link[@href='https://cdn.example.com/dark-theme.css' and @media='(prefers-color-scheme: dark)']",
237+
attribute="XPATH",
238+
)
239+
assert dark_css is not None

0 commit comments

Comments
 (0)