Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
All notable changes to `dash` will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).

## [UNRELEASED]

## Added
- [#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)

## Fixed
- [#3541](https://github.com/plotly/dash/pull/3541) Remove last reference of deprecated `pkg_resources`.

## [3.3.0] - 2025-11-12

## Added
Expand Down
55 changes: 34 additions & 21 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,7 +1015,10 @@ def get_dist(self, libraries: Sequence[str]) -> list:
dists.append(dict(type=dist_type, url=src))
return dists

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

if not is_dynamic_resource and not excluded:
srcs.append(
_relative_url_path(
relative_package_path=rel_path,
namespace=resource["namespace"],
)
url = _relative_url_path(
relative_package_path=rel_path,
namespace=resource["namespace"],
)
if "attributes" in resource:
srcs.append({url_attr: url, **resource["attributes"]})
else:
srcs.append(url)
elif "external_url" in resource:
if not is_dynamic_resource and not excluded:
if isinstance(resource["external_url"], str):
srcs.append(resource["external_url"])
else:
srcs += resource["external_url"]
urls = (
[resource["external_url"]]
if isinstance(resource["external_url"], str)
else resource["external_url"]
)
for url in urls:
if "attributes" in resource:
srcs.append({url_attr: url, **resource["attributes"]})
else:
srcs.append(url)
elif "absolute_path" in resource:
raise Exception("Serving files from absolute_path isn't supported yet")
elif "asset_path" in resource:
static_url = self.get_asset_url(resource["asset_path"])
url_with_cache = static_url + f"?m={resource['ts']}"
# Import .mjs files with type=module script tag
if static_url.endswith(".mjs"):
srcs.append(
{
"src": static_url
+ f"?m={resource['ts']}", # Add a cache-busting query param
"type": "module",
}
)
attrs = {url_attr: url_with_cache, "type": "module"}
if "attributes" in resource:
attrs.update(resource["attributes"])
srcs.append(attrs)
else:
srcs.append(
static_url + f"?m={resource['ts']}"
) # Add a cache-busting query param
if "attributes" in resource:
srcs.append(
{url_attr: url_with_cache, **resource["attributes"]}
)
else:
srcs.append(url_with_cache)

return srcs

Expand All @@ -1096,7 +1108,8 @@ def _generate_css_dist_html(self):
external_links = self.config.external_stylesheets
links = self._collect_and_register_resources(
self.css.get_all_css()
+ self.css._resources._filter_resources(self._hooks.hooks._css_dist)
+ self.css._resources._filter_resources(self._hooks.hooks._css_dist),
url_attr="href",
)

return "\n".join(
Expand Down
14 changes: 12 additions & 2 deletions dash/development/component_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import argparse
import shutil
import functools
import pkg_resources
import importlib.resources as importlib_resources

import yaml

from ._r_components_generation import write_class_file
Expand Down Expand Up @@ -57,7 +58,16 @@ def generate_components(

is_windows = sys.platform == "win32"

extract_path = pkg_resources.resource_filename("dash", "extract-meta.js")
# Get path to extract-meta.js using importlib.resources
try:
# Python 3.9+
extract_path = str(
importlib_resources.files("dash").joinpath("extract-meta.js")
)
except AttributeError:
# Python 3.8 fallback
with importlib_resources.path("dash", "extract-meta.js") as p:
extract_path = str(p)

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

Expand Down
3 changes: 3 additions & 0 deletions dash/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"external_only": bool,
"filepath": str,
"dev_only": bool,
"attributes": _t.Dict[str, str],
},
total=False,
)
Expand Down Expand Up @@ -80,6 +81,8 @@ def _filter_resources(
)
if "namespace" in s:
filtered_resource["namespace"] = s["namespace"]
if "attributes" in s:
filtered_resource["attributes"] = s["attributes"]

if "external_url" in s and (
s.get("external_only") or not self.config.serve_locally
Expand Down
1 change: 0 additions & 1 deletion requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ pandas>=1.4.0
pyarrow
pylint==3.0.3
pytest-mock
pytest-sugar==0.9.6
pyzmq>=26.0.0
xlrd>=2.0.1
pytest-rerunfailures
Expand Down
117 changes: 117 additions & 0 deletions tests/integration/dash_assets/test_dash_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,120 @@ def test_dada002_external_files_init(dash_duo):

# ensure ramda was loaded before the assets so they can use it.
assert dash_duo.find_element("#ramda-test").text == "Hello World"


def test_dada003_external_resources_with_attributes(dash_duo):
"""Test that attributes field works for external scripts and stylesheets"""
app = Dash(__name__)

# Test scripts with type="module" and other attributes
app.scripts.append_script(
{
"external_url": "https://cdn.example.com/module-script.js",
"attributes": {"type": "module"},
"external_only": True,
}
)

app.scripts.append_script(
{
"external_url": "https://cdn.example.com/async-script.js",
"attributes": {"async": "true", "data-test": "custom"},
"external_only": True,
}
)

# Test CSS with custom attributes
app.css.append_css(
{
"external_url": "https://cdn.example.com/print-styles.css",
"attributes": {"media": "print"},
"external_only": True,
}
)

app.layout = html.Div("Test Layout", id="content")

dash_duo.start_server(app)

# Verify script with type="module" is rendered correctly
module_script = dash_duo.find_element(
"//script[@src='https://cdn.example.com/module-script.js' and @type='module']",
attribute="XPATH",
)
assert (
module_script is not None
), "Module script should be present with type='module'"

# Verify script with async and custom data attribute
async_script = dash_duo.find_element(
"//script[@src='https://cdn.example.com/async-script.js' and @async='true' and @data-test='custom']",
attribute="XPATH",
)
assert (
async_script is not None
), "Async script should be present with custom attributes"

# Verify CSS with media attribute
print_css = dash_duo.find_element(
"//link[@href='https://cdn.example.com/print-styles.css' and @media='print']",
attribute="XPATH",
)
assert print_css is not None, "Print CSS should be present with media='print'"


def test_dada004_external_scripts_init_with_attributes(dash_duo):
"""Test that attributes work when passed via external_scripts in Dash constructor"""
js_files = [
"https://cdn.example.com/regular-script.js",
{"src": "https://cdn.example.com/es-module.js", "type": "module"},
{
"src": "https://cdn.example.com/integrity-script.js",
"integrity": "sha256-test123",
"crossorigin": "anonymous",
},
]

css_files = [
"https://cdn.example.com/regular-styles.css",
{
"href": "https://cdn.example.com/dark-theme.css",
"media": "(prefers-color-scheme: dark)",
},
]

app = Dash(__name__, external_scripts=js_files, external_stylesheets=css_files)
app.layout = html.Div("Test", id="content")

dash_duo.start_server(app)

# Verify regular script (string format)
dash_duo.find_element(
"//script[@src='https://cdn.example.com/regular-script.js']", attribute="XPATH"
)

# Verify ES module script
module_script = dash_duo.find_element(
"//script[@src='https://cdn.example.com/es-module.js' and @type='module']",
attribute="XPATH",
)
assert module_script is not None

# Verify script with integrity and crossorigin
integrity_script = dash_duo.find_element(
"//script[@src='https://cdn.example.com/integrity-script.js' and @integrity='sha256-test123' and @crossorigin='anonymous']",
attribute="XPATH",
)
assert integrity_script is not None

# Verify regular CSS
dash_duo.find_element(
"//link[@href='https://cdn.example.com/regular-styles.css']", attribute="XPATH"
)

# Verify CSS with media query
dark_css = dash_duo.find_element(
"//link[@href='https://cdn.example.com/dark-theme.css' and @media='(prefers-color-scheme: dark)']",
attribute="XPATH",
)
assert dark_css is not None
Loading
Loading