holoviz/panel · #8848
Fix notebook resources on RequireJS pages when the public CDNs are unreachable
panel/_templates/autoload_panel_js.js135 + / 2 −
@@ -39,6 +39,70 @@ calls it with the rendered model. return !live_notebook && index !== -1 ? CDN_DIST + url.slice(index + JUPYTER_EXTENSION_PATH.length) : url; } + // Pending until this bootstrap has loaded everything (or given up), so+ // that output rendered meanwhile can wait for Panel's models.+ let bootstrap_resolve;+ const bootstrap = new Promise((resolve) => { bootstrap_resolve = resolve });+ root.__panel_bootstrap__ = bootstrap;+ function bootstrap_done() {+ if (root.__panel_bootstrap__ === bootstrap) {+ root.__panel_bootstrap__ = null;+ }+ bootstrap_resolve();+ }+ setTimeout(bootstrap_done, Math.max({{ timeout|default(0)|json }}, 15000));++ // jupyter_bokeh, which renders Panel output for comms='ipywidgets' and in+ // VS Code, builds a view as soon as its output arrives, so a notebook run+ // top to bottom can render before BokehJS has loaded or before panel.js+ // has registered the models the view needs. Both the classic notebook and+ // VS Code load jupyter_bokeh through RequireJS, so its fetch is held until+ // the bootstrap is done, and should it already be loaded, ipywidgets+ // awaits render(), which is held instead.+ function hold_bokeh_views(module) {+ const View = module?.BokehView;+ if (View == null || View.prototype.__panel_hold__ === true) {+ return;+ }+ const render = View.prototype.render;+ View.prototype.render = function(...args) {+ const pending = root.__panel_bootstrap__;+ return pending == null ? render.apply(this, args) : pending.then(() => render.apply(this, args));+ };+ View.prototype.__panel_hold__ = true;+ }+ if (root.requirejs != null) {+ for (const name of ["@bokeh/jupyter_bokeh", "nbextensions/jupyter_bokeh/index"]) {+ if (root.requirejs.defined(name)) {+ hold_bokeh_views(root.requirejs(name));+ }+ }+ const load = root.requirejs.load;+ if (load?.__panel_hold__ !== true) {+ root.requirejs.load = function(context, name, url) {+ const pending = root.__panel_bootstrap__;+ if (pending != null && name.includes("jupyter_bokeh")) {+ pending.then(() => load.call(this, context, name, url));+ return;+ }+ return load.call(this, context, name, url);+ };+ root.requirejs.load.__panel_hold__ = true;+ }+ const on_resource_load = root.requirejs.onResourceLoad;+ if (on_resource_load?.__panel_hold__ !== true) {+ // Called once a module is defined but before it is handed to the+ // widget manager that asked for it.+ root.requirejs.onResourceLoad = function(context, map, deps) {+ if (map.id.includes("jupyter_bokeh")) {+ hold_bokeh_views(context.defined[map.id]);+ }+ return on_resource_load?.apply(this, arguments);+ };+ root.requirejs.onResourceLoad.__panel_hold__ = true;+ }+ }+ // Set a timeout for this load but only if we are not already initializing if (typeof (root._bokeh_timeout) === "undefined" || (force || !root._bokeh_is_initializing)) { root._bokeh_timeout = Date.now() + {{ timeout|default(0)|json }};@@ -132,8 +196,57 @@ calls it with the rendered model. return true; } + // Mirrors the AMD guard in panel/models/resources.ts: a UMD library that+ // is not loaded by RequireJS must take its browser-global branch, so+ // define.amd reads as undefined while one of these scripts executes. A+ // module script has no currentScript, so it is told apart by the+ // directory its code was loaded from.+ function install_amd_guard() {+ const define = root.define;+ if (typeof define !== "function" || define.amd == null || define.__panel_no_amd__ === true) {+ return;+ }+ const dirs = root.__panel_no_amd_dirs__ = root.__panel_no_amd_dirs__ || [];+ const hidden = () => {+ const script = document.currentScript;+ if (script != null) {+ return script.hasAttribute("data-panel-no-amd");+ }+ const stack = new Error().stack || "";+ return dirs.some((dir) => stack.includes(dir));+ };+ let amd = define.amd;+ try {+ Object.defineProperty(define, "amd", {+ configurable: true,+ enumerable: true,+ get: () => hidden() ? undefined : amd,+ set: (value) => { amd = value },+ });+ Object.defineProperty(define, "__panel_no_amd__", {value: true});+ } catch (e) {+ console.warn(`Panel: could not hide RequireJS from component libraries: ${e}`);+ }+ }++ function hide_amd(element) {+ element.setAttribute("data-panel-no-amd", "");+ install_amd_guard();+ }++ function hide_amd_module(url) {+ const dirs = root.__panel_no_amd_dirs__ = root.__panel_no_amd_dirs__ || [];+ const dir = new URL(url, document.baseURI).href.split("?")[0].replace(/[^/]*$/, "");+ // panel.js itself lives in a dist root, and must never match.+ if (!/\/(dist|static\/extensions\/panel)\/$/.test(dir) && !dirs.includes(dir)) {+ dirs.push(dir);+ }+ install_amd_guard();+ }+ function inject_script_tag(url) { const element = document.createElement('script');+ hide_amd(element); element.onload = on_load; element.onerror = () => { if (!fallback_to_cdn(element, url, "src", document.head)) {@@ -152,8 +265,15 @@ calls it with the rendered model. root._bokeh_is_loading = 1; if (window.requirejs) { const require_config = {{ config|conffilter }};+ // RequireJS tries each path in turn, so a bundled copy the Jupyter+ // extension cannot serve falls back to the CDN, then to the original.+ const require_urls = (url) => {+ url = resource_url(url);+ const index = url.indexOf(JUPYTER_EXTENSION_PATH);+ return index === -1 ? [url] : [url, CDN_DIST + url.slice(index + JUPYTER_EXTENSION_PATH.length)];+ }; for (const [name, path] of Object.entries(require_config.paths || {})) {- require_config.paths[name] = Array.isArray(path) ? path.map(resource_url) : resource_url(path);+ require_config.paths[name] = (Array.isArray(path) ? path : [path]).flatMap(require_urls); } window.requirejs.config(require_config); {% if requirements %}@@ -165,6 +285,11 @@ calls it with the rendered model. define("{{ r }}{{ global_suffix }}", ["{{ r }}"], function(module) { const name = "{{ exports[r] }}" const existing = window[name]+ // A library that never calls define() (plotly.js) resolves to+ // undefined but has assigned its global itself.+ if (module == null) {+ return existing+ } // Several packages can contribute to one namespace (deck.gl's core, // json and carto bundles all publish `deck`), so merge into a fresh // object rather than overwrite.@@ -288,6 +413,7 @@ calls it with the rendered model. continue; } var element = document.createElement('script');+ hide_amd_module(resource_url(url)); root._bokeh_is_loading++; element.onerror = () => { if (!fallback_to_cdn(element, url, "src", document.head)) {@@ -338,7 +464,10 @@ calls it with the rendered model. {%- endfor %} {%- for js in (bundle.js_raw if bundle else js_raw) %} function(Bokeh, define, module, exports) {- {{ js|indent(6) }}+ {# No indent filter: it splits on every character Python's splitlines()+ treats as a line break, which minified libraries (xterm.js) carry+ inside their string literals. #}+{{ js }} }, {% endfor -%} function(Bokeh, define, module, exports) {} // ensure no trailing comma for IE@@ -413,11 +542,15 @@ calls it with the rendered model. } } }+ // Views waiting on this run once the current task is done, by which+ // point the load callback has also restored root.Bokeh.+ bootstrap_done(); } else if (Date.now() < root._bokeh_timeout) { setTimeout(run_inline_js, 100); } else if (!root._bokeh_failed_load) { console.log("Bokeh: BokehJS failed to load within specified timeout."); root._bokeh_failed_load = true;+ bootstrap_done(); } root._bokeh_is_initializing = false; }panel/io/notebook.py105 + / 27 −
@@ -44,7 +44,7 @@ from .model import add_to_doc, diff from .resources import ( CDN_DIST, DIST_DIR, PANEL_DIR, Resources, _env, bundle_resources,- patch_model_css, set_default_resource_mode, set_resource_mode,+ is_cdn_url, patch_model_css, set_default_resource_mode, set_resource_mode, ) from .state import state @@ -348,46 +348,102 @@ def _cdn_url_key(url): return url[:-3] if url.endswith('.js') else url -def _require_covered_urls(model, model_require, resources=None):+def _bundled_require_path(path_key, raw_key, url): """- Resolved urls of a model's scripts that RequireJS is going to load.+ The RequireJS path for a bundled file, given the keys of the path it+ replaces and of the resource it was bundled from, or None if the two+ name different files.+ """+ stem = url[:-3] if url.endswith('.js') else None+ if raw_key == path_key:+ # RequireJS appends `.js` to every path unless it has a query, so a+ # bundled file without that extension needs an (empty) one.+ return stem if stem is not None else f'{url}?'+ if path_key.startswith(f'{raw_key}/'):+ # A resource url may name the package rather than a file in it+ # (``vega@6.1.2``, which the cdn resolves to the package default),+ # while a RequireJS path always names the file+ # (``vega@6.1.2/build/vega.min``). Both refer to the same library.+ return stem if stem is not None else f'{url}?'+ rest = raw_key[len(path_key):]+ if raw_key.startswith(f'{path_key}/') and stem is not None and stem.endswith(rest):+ # A path naming a directory (``ace`` resolving ``ace/ace``) maps to+ # the directory the bundled files live in.+ return stem[:-len(rest)]+ return None - Only those may be skipped: leaving a covered url in would fetch it- twice and corrupt RequireJS' resolution, while skipping an uncovered- one would mean nothing loads it at all.++def _require_bundled_paths(model, paths, resources=None):+ """+ Matches a model's RequireJS paths against its bundled resources.++ Returns the resolved urls of the bundled scripts RequireJS is going to+ load, which must not also get a script tag (that would fetch them twice+ and corrupt RequireJS' resolution), and for each matched path the url+ of its bundled copy. Pointing RequireJS at the bundled copy is what+ keeps the notebook working where the public npm CDNs are unreachable. """- paths = model_require.get('paths', {}) or {}- path_keys = set()- for value in paths.values():+ path_keys = {}+ for name, value in (paths or {}).items(): for url in (value if isinstance(value, (list, tuple)) else (value,)): if isinstance(url, str):- path_keys.add(_cdn_url_key(url))+ path_keys.setdefault(name, _cdn_url_key(url)) if not path_keys:- return []+ return [], {} try: raw = list(getattr(model, '__javascript_raw__', None) or []) declared = list(getattr(model, '__javascript__', None) or []) except Exception:- return []+ return [], {} if len(raw) != len(declared):- return []+ return [], {} resolved = resources.adjust_paths(declared) if resources is not None else declared- covered = []- for raw_url, url in zip(raw, resolved):+ covered, bundled = [], {}+ for raw_url, declared_url, url in zip(raw, declared, resolved): if not isinstance(raw_url, str): continue raw_key = _cdn_url_key(raw_url)- # A resource url may name the package rather than a file in it- # (``vega@6.1.2``, which the cdn resolves to the package default),- # while a RequireJS path always names the file- # (``vega@6.1.2/build/vega.min``). Both refer to the same library.- if raw_key in path_keys or any(- key.startswith(f'{raw_key}/') for key in path_keys- ):+ matched = False+ for name, path_key in path_keys.items():+ path = _bundled_require_path(path_key, raw_key, url)+ if path is None:+ continue+ matched = True+ if name not in bundled and declared_url != raw_url:+ bundled[name] = (path, declared_url)+ if matched: covered.append(url)- return covered+ return covered, bundled+++def _strip_require(model_require, names):+ """+ Removes paths from a RequireJS config, along with their exports, shims+ and any shim dependencies on them.+ """+ removed = set()+ paths = {}+ for key, value in model_require.get('paths', {}).items():+ if key in names:+ removed.update(key[1] if isinstance(key, tuple) else (key,))+ else:+ paths[key] = value+ model_require['paths'] = paths+ if 'exports' in model_require:+ model_require['exports'] = {+ k: v for k, v in model_require['exports'].items() if k not in removed+ }+ if 'shim' in model_require:+ shim = {}+ for name, conf in model_require['shim'].items():+ if name in removed:+ continue+ if conf.get('deps'):+ conf = dict(conf, deps=[d for d in conf['deps'] if d not in removed])+ shim[name] = conf+ model_require['shim'] = shim def _resolve_js_skip(skip, resources=None):@@ -418,12 +474,15 @@ def require_components(resources=None): let panel.io.resource_spec derive the rest. ``resources``, if given, resolves the ``__js_skip__`` urls so they- match what the bundle emits.+ match what the bundle emits, and points RequireJS at Panel's bundled+ copies of the libraries rather than at the npm CDN, or drops them from+ RequireJS altogether when they are inlined. """ from ..config import config, panel_extension configs, requirements, exports = [], [], {} js_requires = []+ inline = resources is not None and resources.mode == 'inline' active_modules = tuple( panel_extension._imports[extension] for extension in panel_extension._loaded_extensions@@ -464,9 +523,28 @@ def require_components(resources=None): model_require = model else: model_require = dict(model.__js_require__)- for url in _require_covered_urls(model, model_require, resources):- if url not in require_skip:- require_skip.append(url)+ paths = dict(model_require.get('paths', {}))+ covered, bundled = _require_bundled_paths(model, paths, resources)+ if inline:+ # The inlined copy already assigned the global, since the+ # bootstrap shadows `define` for inline code, so RequireJS+ # would only fetch the library a second time.+ _strip_require(model_require, {+ name for name, (_, url) in bundled.items() if is_cdn_url(url)+ })+ if not model_require['paths']:+ continue+ else:+ for name, (path, _) in bundled.items():+ original = paths[name]+ fallbacks = list(original) if isinstance(original, (list, tuple)) else [original]+ # The original url stays as a fallback for when the+ # bundled copy is unavailable.+ paths[name] = [path, *(url for url in fallbacks if url != path)]+ model_require['paths'] = paths+ for url in covered:+ if url not in require_skip:+ require_skip.append(url) model_exports = model_require.pop('exports', {}) if not any(model_require == config for config in configs):panel/models/deckgl.py4 + / 31 −
@@ -61,37 +61,10 @@ def __js_skip__(cls): 'CartoLibrary': cls.__javascript__[8:], } - __js_require__ = {- 'paths': {- "h3": f"{config.npm_cdn}/h3-js@4.1.0/dist/h3-js.umd",- "deck-gl": f"{config.npm_cdn}/deck.gl@{DECKGL_VERSION}/dist.min",- "deck-json": f"{config.npm_cdn}/@deck.gl/json@{DECKGL_VERSION}/dist.min",- "loader-csv": f"{config.npm_cdn}/@loaders.gl/csv@4.2.2/dist/dist.min",- "loader-json": f"{config.npm_cdn}/@loaders.gl/json@4.2.2/dist/dist.min",- "loader-tiles": f"{config.npm_cdn}/@loaders.gl/3d-tiles@4.2.2/dist/dist.min",- "mapbox-gl": "https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl",- # Without a path of its own, RequireJS' presence would send its- # UMD down the AMD branch instead of assigning window.maplibregl.- "maplibre-gl": f"{config.npm_cdn}/maplibre-gl/dist/maplibre-gl",- "carto": f"{config.npm_cdn}/@deck.gl/carto@^{DECKGL_VERSION}/dist.min",- },- 'exports': {- "deck-gl": "deck", "mapbox-gl": "mapboxgl", "h3": "h3",- "maplibre-gl": "maplibregl",- # json and carto extend the deck namespace; the three loaders- # bundles together make up window.loaders.- "deck-json": "deck", "carto": "deck",- "loader-csv": "loaders", "loader-json": "loaders",- "loader-tiles": "loaders",- },- 'shim': {- # carto and the json catalogue extend deck.gl classes off- # window.deck while their own factories run.- 'deck-json': {'deps': ["deck-gl"]},- 'carto': {'deps': ["deck-gl"]},- 'deck-gl': {'deps': ["h3"]}- }- }+ # No __js_require__: deck.gl's json and carto bundles embed UMD copies+ # of their dependencies and call define() anonymously more than once,+ # which RequireJS rejects. Loaded by script tag instead, they take their+ # browser-global branch even on a page with RequireJS. data = Dict(String, Any) panel/models/deckgl.ts9 + / 2 −
@@ -58,6 +58,9 @@ export class DeckGLPlotView extends LayoutDOMView { const error = await await_resources(this.model) if (error != null) { render_resource_error(this, error, LayoutDOMView.prototype.render)+ } else if (this.jsonConverter == null) {+ // Loaded on demand, deck.gl only arrives after initialize() ran.+ this._init_converter() } } @@ -97,7 +100,11 @@ export class DeckGLPlotView extends LayoutDOMView { super.initialize() const view_timeout = this.model.throttle.view || 200 this._view_cb = debounce((event: any) => this._on_viewState_event(event), view_timeout, false)- if ((window as any).deck.JSONConverter) {+ this._init_converter()+ }++ _init_converter(): void {+ if ((window as any).deck?.JSONConverter) { const {CSVLoader, Tiles3DLoader} = (window as any).loaders; (window as any).loaders.registerLoaders([Tiles3DLoader, CSVLoader]) const jsonConverterConfiguration: any = {@@ -242,7 +249,7 @@ export class DeckGLPlotView extends LayoutDOMView { this.jsonConverter.mergeConfiguration(configuration) const props = this.jsonConverter.convert(jsonInput) const getTooltip = makeTooltip(tooltip, props.layers)- if (props.mapStyle === null) {+ if (props.mapStyle == null) { props.map = null } else if (props.mapStyle.includes("carto")) { this._map = new (window as any).maplibregl.Map({panel/models/resources.ts61 + / 0 −
@@ -127,6 +127,65 @@ function inject(el: HTMLScriptElement | HTMLLinkElement): Promise<void> { }) } +/**+ * Hides RequireJS from the scripts the registry loads while they execute.+ *+ * On a page with RequireJS (the classic notebook, VS Code) a UMD bundle+ * finds `define.amd` and registers an anonymous AMD module instead of+ * assigning the global the components read, and RequireJS attributes that+ * anonymous define() to whichever script it loads next. Hiding `define`+ * itself for as long as the scripts load would also hide it from whatever+ * RequireJS loads in the meantime, so instead `define.amd` reads as+ * undefined only while Panel's own code is running: a classic script is+ * recognized by a marker attribute, a module script, which has no+ * `document.currentScript`, by the directory it was loaded from.+ */+const NO_AMD_ATTR = "data-panel-no-amd"++function install_amd_guard(): void {+ const global = globalThis as any+ const define = global.define+ if (typeof define !== "function" || define.amd == null || define.__panel_no_amd__ === true) {+ return+ }+ const dirs: string[] = global.__panel_no_amd_dirs__ ??= []+ const hidden = (): boolean => {+ const script = document.currentScript+ if (script != null) {+ return script.hasAttribute(NO_AMD_ATTR)+ }+ const stack = new Error().stack ?? ""+ return dirs.some((dir) => stack.includes(dir))+ }+ let amd = define.amd+ try {+ Object.defineProperty(define, "amd", {+ configurable: true,+ enumerable: true,+ get: () => hidden() ? undefined : amd,+ set: (value) => { amd = value },+ })+ Object.defineProperty(define, "__panel_no_amd__", {value: true})+ } catch (e) {+ console.warn(`Panel: could not hide RequireJS from component libraries: ${e}`)+ }+}++function hide_amd(el: HTMLScriptElement): void {+ el.setAttribute(NO_AMD_ATTR, "")+ install_amd_guard()+}++function hide_amd_module(url: string): void {+ const dirs: string[] = (globalThis as any).__panel_no_amd_dirs__ ??= []+ const dir = url_key(url).split("?")[0].replace(/[^/]*$/, "")+ // panel.js itself lives in a dist root, and must never match.+ if (!/\/(dist|static\/extensions\/panel)\/$/.test(dir) && !dirs.includes(dir)) {+ dirs.push(dir)+ }+ install_amd_guard()+}+ /** * Injects a classic script tag. *@@ -139,6 +198,7 @@ function inject_script(url: string): Promise<void> { const el = document.createElement("script") el.async = false el.src = resource_url(url)+ hide_amd(el) return inject(el) } @@ -162,6 +222,7 @@ function inject_module(url: string, name?: string): Promise<void> { const el = document.createElement("script") el.type = "module" el.async = false+ hide_amd_module(url) if (name == null) { el.src = resource_url(url) return inject(el)panel/tests/io/test_notebook.py101 + / 4 −
@@ -9,12 +9,12 @@ from panel.config import config, panel_extension from panel.io import resources as resources_module from panel.io.notebook import (- LOAD_MIME, ipywidget, load_notebook, replace_inline_css,- require_components,+ LOAD_MIME, _autoload_js, _bundled_require_path, _cdn_url_key, ipywidget,+ load_notebook, replace_inline_css, require_components, ) from panel.io.resource_spec import _SPEC_CACHE, resource_spec from panel.io.resources import (- CDN_DIST, CDN_ROOT, JS_VERSION, set_resource_mode,+ CDN_DIST, CDN_ROOT, JS_VERSION, Bundle, Resources, set_resource_mode, ) from panel.layout import Column from panel.models.echarts import ECharts@@ -181,6 +181,103 @@ def test_require_components_only_includes_active_panel_extensions(monkeypatch): assert any('echarts' in config['paths'] for config in configs) +def _tabulator_require(monkeypatch, mode, notebook):+ from bokeh.core.has_props import _default_resolver+ from bokeh.resources import CDN, INLINE++ monkeypatch.setattr(panel_extension, '_loaded_extensions', ['tabulator'])+ monkeypatch.setitem(+ _default_resolver._known_models, 'panel.models.tabulator.DataTabulator', DataTabulator+ )+ base = INLINE if mode == 'inline' else CDN+ with set_resource_mode(mode):+ resources = Resources.from_bokeh(base, notebook=notebook)+ return require_components(resources)+++@pytest.mark.parametrize('notebook', [True, False])+def test_require_components_load_bundled_copies(monkeypatch, notebook):+ """+ RequireJS must load Panel's bundled copies rather than the npm CDN,+ which a notebook on a restricted network cannot reach.++ See https://github.com/holoviz/panel/issues/8750.+ """+ configs, requirements, _, _, require_skip = _tabulator_require(monkeypatch, 'cdn', notebook)++ paths = next(config['paths'] for config in configs if 'tabulator' in config['paths'])+ bundled, original = paths['tabulator']+ dist = '/panel-preview/static/extensions/panel/' if notebook else CDN_DIST+ assert bundled == f'{dist}bundled/datatabulator/tabulator-tables@6.4.0/dist/js/tabulator.min'+ assert original == DataTabulator.__js_require__['paths']['tabulator']+ assert f'{bundled}.js' in require_skip+ assert 'tabulator' in requirements+ # The class-level declaration must not be rewritten in place.+ assert isinstance(DataTabulator.__js_require__['paths']['tabulator'], str)+++def test_require_components_skip_inlined_libraries(monkeypatch):+ """+ Inlined libraries assign their globals themselves, so RequireJS must not+ fetch them a second time from the network.+ """+ configs, requirements, exports, _, require_skip = _tabulator_require(monkeypatch, 'inline', True)++ assert not any('tabulator' in config['paths'] for config in configs)+ assert 'tabulator' not in requirements+ assert 'tabulator' not in exports+ assert require_skip == []+++@pytest.mark.parametrize(('path', 'raw', 'url', 'expected'), [+ # The same file+ (+ 'https://cdn.jsdelivr.net/npm/echarts@6.0.0/dist/echarts.min',+ 'https://cdn.jsdelivr.net/npm/echarts@6.0.0/dist/echarts.min.js',+ '/bundled/echarts/echarts@6.0.0/dist/echarts.min.js',+ '/bundled/echarts/echarts@6.0.0/dist/echarts.min',+ ),+ # A package resolved to its default file, bundled without `.js`+ (+ 'https://cdn.jsdelivr.net/npm/vega@6.1.2/build/vega.min',+ 'https://cdn.jsdelivr.net/npm/vega@6.1.2',+ '/bundled/vegaplot/vega@6.1.2',+ '/bundled/vegaplot/vega@6.1.2?',+ ),+ # A path naming the directory the bundled files live in+ (+ '//cdn.jsdelivr.net/npm/ace-builds@1.40.1/src-min-noconflict',+ 'https://cdn.jsdelivr.net/npm/ace-builds@1.40.1/src-min-noconflict/ace.js',+ '/bundled/aceplot/ace-builds@1.40.1/src-min-noconflict/ace.js',+ '/bundled/aceplot/ace-builds@1.40.1/src-min-noconflict',+ ),+ # Unrelated files that only share a prefix+ (+ 'https://cdn.jsdelivr.net/npm/@deck.gl/carto@^9.1.14/dist.min',+ 'https://cdn.jsdelivr.net/npm/@loaders.gl/3d-tiles@4.2.2/dist/dist.min.js',+ '/bundled/deckglplot/@loaders.gl/3d-tiles@4.2.2/dist/dist.min.js',+ None,+ ),+], ids=['file', 'package', 'directory', 'unrelated'])+def test_bundled_require_path(path, raw, url, expected):+ assert _bundled_require_path(_cdn_url_key(path), _cdn_url_key(raw), url) == expected+++def test_autoload_js_preserves_inline_source():+ """+ Characters Python's splitlines() treats as line breaks (xterm.js has+ them in string literals) must reach the page untouched, otherwise the+ whole bootstrap is a syntax error.+ """+ source = 'var C0 = {FS: "\x1c", GS: "\x1d", RS: "\x1e", NEL: "\x85", LS: "\u2028"};'+ bundle = Bundle(js_raw=[source])+ bootstrap = _autoload_js(+ bundle=bundle, configs=[], requirements=[], exports={}, error_id='error',+ skip_imports={}, require_skip=[], ipywidget=False+ )+ assert source in bootstrap++ def test_notebook_endpoint_alert_stays_in_notebook_output(monkeypatch, notebook_bootstrap): """ Endpoint failures must update Panel's output rather than the notebook UI.@@ -208,7 +305,7 @@ def publish_display_data(data, **kwargs): assert 'element.src = resource_url(url)' in bootstrap assert 'element.href = resource_url(url)' in bootstrap assert 'new URL(resource_url(url), document.baseURI)' in bootstrap- assert 'require_config.paths[name] = Array.isArray(path) ? path.map(resource_url) : resource_url(path)' in bootstrap+ assert 'require_config.paths[name] = (Array.isArray(path) ? path : [path]).flatMap(require_urls)' in bootstrap def test_notebook_vscode_resources_use_cdn(monkeypatch, notebook_bootstrap):panel/tests/ui/io/test_nbclassic.py72 + / 30 −
@@ -1,13 +1,18 @@ import json+import pathlib import time +from urllib.parse import unquote+ import pytest import requests pytest.importorskip("playwright") from playwright.sync_api import TimeoutError, expect +from panel.io.resources import CDN_DIST, DIST_DIR+ pytestmark = [pytest.mark.ui, pytest.mark.jupyter, pytest.mark.flaky(reruns=3)] @@ -131,6 +136,47 @@ def run_notebook(page, nbclassic_server, notebook_name, cells): assert not errors, '\n'.join(errors) +#: Hosts the bundled libraries originally come from. Panel must not need+#: them, so a notebook on a network that blocks them still renders.+PUBLIC_CDNS = ('cdn.jsdelivr.net', 'unpkg.com', 'cdn.plot.ly', 'api.mapbox.com')+++def _serve_local_dist(page):+ """+ Serves Panel's CDN urls from the local build, so modes that resolve+ resources against the CDN (comms='ipywidgets', like VS Code) exercise+ this checkout rather than the last release.+ """+ dist = pathlib.Path(DIST_DIR)++ def fulfill(route):+ path = dist / unquote(route.request.url.split('?')[0].removeprefix(CDN_DIST))+ if path.is_file():+ route.fulfill(path=str(path))+ else:+ route.fallback()++ page.route(f'{CDN_DIST}**', fulfill)+++def _enable_jupyter_bokeh(page):+ """+ Maps the widget module the way jupyter_bokeh's nbextension does, which+ is not necessarily enabled where the tests run.+ """+ page.add_init_script("""+ const timer = setInterval(() => {+ if (window.requirejs == null) {+ return+ }+ clearInterval(timer)+ window.requirejs.config({map: {'*': {'@bokeh/jupyter_bokeh': 'nbextensions/jupyter_bokeh/index'}}})+ }, 1)+ """)+++@pytest.mark.parametrize('comms', ['default', 'ipywidgets'])+@pytest.mark.parametrize('mode', ['eager', 'inline', 'lazy']) @pytest.mark.parametrize( ('extension', 'component', 'selector'), [@@ -141,53 +187,49 @@ def run_notebook(page, nbclassic_server, notebook_name, cells): ('jsoneditor', "pn.widgets.JSONEditor(value={'answer': 42}, height=160)", '.jsoneditor'), ('echarts', "pn.pane.ECharts({'xAxis': {}, 'yAxis': {}, 'series': []}, height=180)", '[_echarts_instance_]'), ('vega', "pn.pane.Vega({'$schema': 'https://vega.github.io/schema/vega-lite/v5.json', 'mark': 'bar', 'data': {'values': []}}, height=180)", '.vega-embed'),- pytest.param(- 'deckgl',- "pn.pane.DeckGL({'initialViewState': {'longitude': 0, 'latitude': 0, 'zoom': 1}, 'layers': []}, height=180)",- '.deckgl canvas',- marks=pytest.mark.xfail(- reason=(- "deck.gl's json and carto bundles each call define() more than "- "once anonymously, because they embed UMD copies of their own "- "dependencies (long.js). RequireJS can attribute only one "- "anonymous define per script and rejects the rest with "- "'Mismatched anonymous define()', so window.deck never gains "- "JSONConverter. No paths/shim/exports configuration can fix a "- "script that defines more than one anonymous module; loading "- "them outside RequireJS is the only remedy."- ),- strict=True,- ),- ),+ ('deckgl', "pn.pane.DeckGL({'initialViewState': {'longitude': 0, 'latitude': 0, 'zoom': 1}, 'layers': []}, height=180)", '.deckgl canvas'), ('terminal', 'pn.widgets.Terminal(height=100)', '.xterm'), ('katex', "pn.pane.LaTeX(r'$E = mc^2$')", '.katex'),+ ('plotly', "pn.pane.Plotly({'data': [{'type': 'bar', 'y': [1, 2, 3]}], 'layout': {}}, height=200)", '.js-plotly-plot'), ], ids=[ 'tabulator', 'perspective', 'filedropper', 'codeeditor', 'jsoneditor',- 'echarts', 'vega', 'deckgl', 'terminal', 'katex',+ 'echarts', 'vega', 'deckgl', 'terminal', 'katex', 'plotly', ], )-def test_nbclassic_component_resources(page, nbclassic_server, extension, component, selector):- """Load each bundled component independently through nbclassic resources."""+def test_nbclassic_component_resources(page, nbclassic_server, extension, component, selector, mode, comms):+ """+ Load each bundled component independently through nbclassic resources,+ declared up front (eager), inlined, or only requested on render (lazy).++ comms='ipywidgets' renders through jupyter_bokeh's widget, the way VS+ Code renders Panel output.++ See https://github.com/holoviz/panel/issues/8750.+ """+ if comms == 'ipywidgets':+ pytest.importorskip('jupyter_bokeh')+ _enable_jupyter_bokeh(page)+ _serve_local_dist(page)+ for host in PUBLIC_CDNS:+ page.route(f'**://{host}/**', lambda route: route.abort()) console_errors = [] page.on('console', lambda message: console_errors.append(message.text) if message.type == 'error' else None) page.on('pageerror', lambda error: console_errors.append(str(error)))- run_notebook(page, nbclassic_server, extension, [+ if mode == 'lazy':+ load = f"pn.extension(comms='{comms}', inline=False)"+ else:+ load = f"pn.extension('{extension}', comms='{comms}', inline={mode == 'inline'})"+ run_notebook(page, nbclassic_server, f'{extension}_{mode}_{comms}', [ 'import pandas as pd\nimport panel as pn',- f"pn.extension('{extension}', comms='default', inline=False)",+ load, component, ]) try: expect(page.locator(selector)).to_have_count(1, timeout=10000) except AssertionError as error:- globals_ = page.evaluate("""() => ({- vega: typeof window.vega,- vegaLiteCompile: typeof window.vegaLite?.compile,- vlCompile: typeof window.vl?.compile,- vegaEmbed: typeof window.vegaEmbed,- })""")- raise AssertionError(f'{error}\nVega globals: {globals_}\nConsole errors:\n' + '\n'.join(console_errors)) from error+ raise AssertionError(f'{error}\nConsole errors:\n' + '\n'.join(console_errors)) from error def test_nbclassic_component_comm(page, nbclassic_server):