diff --git a/CHANGELOG.md b/CHANGELOG.md index 056caddd1b..fd6ba04f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/dash.py b/dash/dash.py index d41b97b4f5..72334a956a 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -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 @@ -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 @@ -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( diff --git a/dash/development/component_generator.py b/dash/development/component_generator.py index 276dbfb0f5..18929fe248 100644 --- a/dash/development/component_generator.py +++ b/dash/development/component_generator.py @@ -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 @@ -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) diff --git a/dash/resources.py b/dash/resources.py index ad0e3e7d6b..e197b6753e 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -25,6 +25,7 @@ "external_only": bool, "filepath": str, "dev_only": bool, + "attributes": _t.Dict[str, str], }, total=False, ) @@ -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 diff --git a/requirements/ci.txt b/requirements/ci.txt index 3cbf49b076..c496361ceb 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -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 diff --git a/tests/integration/dash_assets/test_dash_assets.py b/tests/integration/dash_assets/test_dash_assets.py index 023d9c7bc7..fc2d70ad8b 100644 --- a/tests/integration/dash_assets/test_dash_assets.py +++ b/tests/integration/dash_assets/test_dash_assets.py @@ -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 diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index 094cf3978b..413514de18 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -122,3 +122,134 @@ def test_collect_and_register_resources(mocker): ] ) import_mock.assert_any_call("dash_html_components") + + +def test_resources_with_attributes(): + """Test that attributes are passed through in external_url resources""" + app = dash.Dash(__name__) + + # Test external scripts with attributes + resources = app._collect_and_register_resources( + [ + { + "external_url": "https://example.com/module.js", + "attributes": {"type": "module"}, + "external_only": True, + }, + { + "external_url": "https://example.com/script.js", + "attributes": { + "crossorigin": "anonymous", + "integrity": "sha256-abc123", + }, + "external_only": True, + }, + ] + ) + + assert resources == [ + {"src": "https://example.com/module.js", "type": "module"}, + { + "src": "https://example.com/script.js", + "crossorigin": "anonymous", + "integrity": "sha256-abc123", + }, + ] + + +def test_css_resources_with_attributes(): + """Test that attributes are passed through in CSS resources with href""" + app = dash.Dash(__name__) + + # Test external CSS with attributes + resources = app._collect_and_register_resources( + [ + { + "external_url": "https://example.com/styles.css", + "attributes": {"media": "print"}, + "external_only": True, + }, + { + "external_url": "https://example.com/theme.css", + "attributes": {"crossorigin": "anonymous"}, + "external_only": True, + }, + ], + url_attr="href", + ) + + assert resources == [ + {"href": "https://example.com/styles.css", "media": "print"}, + {"href": "https://example.com/theme.css", "crossorigin": "anonymous"}, + ] + + +def test_resources_without_attributes(): + """Test that resources without attributes still work as strings""" + app = dash.Dash(__name__) + + resources = app._collect_and_register_resources( + [ + {"external_url": "https://example.com/script.js", "external_only": True}, + ] + ) + + assert resources == ["https://example.com/script.js"] + + +def test_local_resources_with_attributes(mocker): + """Test that attributes work with local resources""" + mocker.patch("dash.dcc._js_dist") + mocker.patch("dash.dcc.__version__") + dcc._js_dist = [ + { + "relative_package_path": "module.js", + "namespace": "dash_core_components", + "attributes": {"type": "module"}, + } + ] + dcc.__version__ = "1.0.0" + + app = dash.Dash(__name__) + app.layout = dcc.Markdown() + + with mock.patch("dash.dash.os.stat", return_value=StatMock()): + with mock.patch("dash.dash.importlib.import_module", return_value=dcc): + with mock.patch("sys.modules", {"dash_core_components": dcc}): + resources = app._collect_and_register_resources( + [ + { + "relative_package_path": "module.js", + "namespace": "dash_core_components", + "attributes": {"type": "module"}, + } + ] + ) + + assert len(resources) == 1 + assert isinstance(resources[0], dict) + assert "src" in resources[0] + assert resources[0]["type"] == "module" + + +def test_multiple_external_urls_with_attributes(): + """Test that multiple external URLs with attributes work correctly""" + app = dash.Dash(__name__) + + resources = app._collect_and_register_resources( + [ + { + "external_url": [ + "https://example.com/script1.js", + "https://example.com/script2.js", + ], + "attributes": {"type": "module"}, + "external_only": True, + } + ] + ) + + assert resources == [ + {"src": "https://example.com/script1.js", "type": "module"}, + {"src": "https://example.com/script2.js", "type": "module"}, + ]