holoviz/panel · #8762
fix: Fix notebook resource loading with RequireJS and add a Jupyter extension endpoint
panel/_templates/autoload_panel_js.js186 + / 38 −
@@ -26,6 +26,12 @@ calls it with the rendered model. const Bokeh = root.Bokeh; const BK_RE = /^https:\/\/cdn\.bokeh\.org\/bokeh\/(release|dev)\/bokeh-/; const PN_RE = /^https:\/\/cdn\.holoviz\.org\/panel\/[^/]+\/dist\/panel/i;+ const JUPYTER_EXTENSION_PATH = "/panel-preview/static/extensions/panel/";+ const CDN_DIST = {{ cdn_dist|json }};+ // Exposed so the lazy-resource registry (models/resources.ts) can fall+ // back to the CDN for the same endpoint, not just the eager bootstrap+ // resources loaded below.+ root.__panel_cdn_dist__ = CDN_DIST; // 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)) {@@ -45,6 +51,31 @@ calls it with the rendered model. console.debug("Bokeh: all callbacks have finished"); } + function show_jupyter_extension_error() {+ const element = document.getElementById("{{ error_id }}");+ if (element == null || !element.hidden) {+ return;+ }+ element.style.cssText = "color: #b91c1c; font-family: sans-serif; padding: 0.5em;";+ element.textContent = (+ "Panel could not load resources from its Jupyter server extension. " ++ "Install Panel in the environment running the Jupyter server and restart it."+ );+ element.hidden = false;+ }+ root.__panel_jupyter_extension_error__ = show_jupyter_extension_error;++ {% if check_extension %}+ // Proactively checks the extension is actually installed, rather than+ // waiting for some resource's load to fail. A plain HEAD request for the+ // bundle every notebook session needs is enough to tell.+ fetch(JUPYTER_EXTENSION_PATH + "panel.min.js", {method: "HEAD"}).then((response) => {+ if (!response.ok) {+ show_jupyter_extension_error();+ }+ }).catch(() => show_jupyter_extension_error());+ {% endif %}+ function load_libs(css_urls, js_urls, js_modules, Bokeh, callback) { if (css_urls == null) css_urls = []; if (js_urls == null) js_urls = [];@@ -71,25 +102,99 @@ calls it with the rendered model. } window._bokeh_on_load = on_load - function on_error(e) {- const src_el = e.srcElement- console.error("failed to load " + (src_el.href || src_el.src));+ function on_error(url) {+ console.error("failed to load " + url);+ if (url.includes(JUPYTER_EXTENSION_PATH)) {+ show_jupyter_extension_error();+ }+ }++ function fallback_to_cdn(element, url, attribute, parent) {+ const index = url.indexOf(JUPYTER_EXTENSION_PATH);+ if (index === -1 || element.dataset.panelCdnFallback != null) {+ return false;+ }+ element.dataset.panelCdnFallback = "";+ element.remove();+ element[attribute] = CDN_DIST + url.slice(index + JUPYTER_EXTENSION_PATH.length);+ parent.appendChild(element);+ return true;+ }++ function inject_script_tag(url) {+ const element = document.createElement('script');+ element.onload = on_load;+ element.onerror = () => {+ if (!fallback_to_cdn(element, url, "src", document.head)) {+ on_error(url);+ }+ };+ element.async = false;+ element.src = url;+ console.debug("Bokeh: injecting script tag for BokehJS library: ", url);+ document.head.appendChild(element); } const skip = [];+ // Held open until every resource below has been queued, then released+ // by on_load() at the end of this function.+ root._bokeh_is_loading = 1; if (window.requirejs) { window.requirejs.config({{ config|conffilter }});+ {% if requirements %}+ // Assigns each library's global as its own module resolves, since a+ // library whose factory reads another's global (e.g. deck.gl's carto+ // layers reading window.deck) runs during the batch, not after it. {% for r in requirements %}- require(["{{ r }}"], function({{ exports[r] }}) {- {% if r in exports %}- window.{{ exports[r] }} = {{ exports[r] }}- {% endif %}- on_load()+ {% if r in exports %}+ define("{{ r }}{{ global_suffix }}", ["{{ r }}"], function(module) {+ const name = "{{ exports[r] }}"+ const existing = window[name]+ // 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.+ if (existing != null && typeof existing === "object" &&+ module != null && typeof module === "object") {+ window[name] = Object.assign({}, existing, module)+ } else {+ window[name] = module+ }+ return window[name] })+ {% endif %} {% endfor %}- root._bokeh_is_loading = css_urls.length + {{ requirements|length }};- } else {- root._bokeh_is_loading = css_urls.length + js_urls.length + js_modules.length;+ root._bokeh_is_loading++;+ // Required in stages so a library reading another's global finds it+ // assigned, and so one failing library doesn't stop the rest.+ const require_stages = {{ require_stages|default([])|json }};+ const assign_resolved = () => {+ {% for r in requirements %}+ {% if r in exports %}+ if (window.requirejs.defined("{{ r }}{{ global_suffix }}")) {+ require("{{ r }}{{ global_suffix }}")+ }+ {% endif %}+ {% endfor %}+ };+ const require_stage = (index) => {+ if (index >= require_stages.length) {+ // Only now are the globals the components read actually assigned.+ require_ready_resolve()+ on_load()+ return+ }+ require(require_stages[index], () => require_stage(index + 1), (error) => {+ const modules = error.requireModules ? error.requireModules.join(', ') : 'unknown';+ console.error(`Panel: requirejs failed to load ${modules}: ${error.requireType} ${error.message}`);+ // Publish whatever this stage did resolve, so that one library+ // failing does not blank every component on the page.+ assign_resolved()+ on_error(modules)+ require_stage(index + 1)+ })+ };+ require_stage(0)+ {% endif %} } const existing_stylesheets = []@@ -104,12 +209,16 @@ calls it with the rendered model. const url = css_urls[i]; const escaped = encodeURI(url) if (existing_stylesheets.indexOf(escaped) !== -1) {- on_load() continue; } const element = document.createElement("link");+ root._bokeh_is_loading++; element.onload = on_load;- element.onerror = on_error;+ element.onerror = () => {+ if (!fallback_to_cdn(element, url, "href", document.body)) {+ on_error(url);+ }+ }; element.rel = "stylesheet"; element.type = "text/css"; element.href = url;@@ -118,13 +227,24 @@ calls it with the rendered model. } {%- for lib, urls in skip_imports.items() %}- if (((window.{{ lib }} !== undefined) && (!(window.{{ lib }} instanceof HTMLElement))) || window.requirejs) {+ // The global is already there, so re-fetching what provides it is waste.+ if ((window.{{ lib }} !== undefined) && (!(window.{{ lib }} instanceof HTMLElement))) { var urls = {{ urls }}; for (var i = 0; i < urls.length; i++) { skip.push(encodeURI(urls[i])) } } {%- endfor %}+ {%- if require_skip %}+ // RequireJS already has its own path for these, so a script tag would+ // load them a second time and corrupt RequireJS' module resolution.+ if (window.requirejs) {+ var urls = {{ require_skip|json }};+ for (var i = 0; i < urls.length; i++) {+ skip.push(encodeURI(urls[i]))+ }+ }+ {%- endif %} var existing_scripts = [] const scripts = document.getElementsByTagName('script') for (let i = 0; i < scripts.length; i++) {@@ -140,31 +260,25 @@ calls it with the rendered model. const isBokehOrPanel = BK_RE.test(escaped) || PN_RE.test(escaped) const missingOrBroken = Bokeh == null || Bokeh.Panel == null || (Bokeh.version != version && !Bokeh.versions?.has(version)) || Bokeh.versions?.get(version)?.Panel == null; if (shouldSkip && !(isBokehOrPanel && missingOrBroken)) {- if (!window.requirejs) {- on_load();- } continue; }- const element = document.createElement('script');- element.onload = on_load;- element.onerror = on_error;- element.async = false;- element.src = url;- console.debug("Bokeh: injecting script tag for BokehJS library: ", url);- document.head.appendChild(element);+ root._bokeh_is_loading++;+ inject_script_tag(url); } for (let i = 0; i < js_modules.length; i++) { const [url, name] = js_modules[i]; const escaped = encodeURI(url) const loaded = name == null ? existing_scripts.indexOf(escaped) !== -1 : root[name] != null if (skip.indexOf(escaped) !== -1 || loaded) {- if (!window.requirejs) {- on_load();- } continue; } var element = document.createElement('script');- element.onerror = on_error;+ root._bokeh_is_loading++;+ element.onerror = () => {+ if (!fallback_to_cdn(element, url, "src", document.head)) {+ on_error(url);+ }+ }; element.async = false; element.type = "module"; if (name == null) {@@ -187,9 +301,9 @@ calls it with the rendered model. console.debug("Bokeh: injecting script tag for BokehJS library: ", url); document.head.appendChild(element); }- if (!js_urls.length && !js_modules.length) {- on_load()- }+ // Releases the reservation taken above, running the callbacks now if+ // nothing else is outstanding.+ on_load() }; function inject_raw_css(css) {@@ -208,13 +322,25 @@ calls it with the rendered model. }, {%- endfor %} {%- for js in (bundle.js_raw if bundle else js_raw) %}- function(Bokeh) {+ function(Bokeh, define, module, exports) { {{ js|indent(6) }} }, {% endfor -%}- function(Bokeh) {} // ensure no trailing comma for IE+ function(Bokeh, define, module, exports) {} // ensure no trailing comma for IE ]; + // Resolved once RequireJS has loaded its libraries and assigned their+ // globals, so the resource registry can wait for them instead of racing.+ let require_ready_resolve;+ const require_ready = new Promise((resolve) => { require_ready_resolve = resolve });+ {%- if requirements %}+ // A claimed library that is never released would keep its components from+ // ever rendering, so the wait is bounded by the same timeout as the load.+ setTimeout(() => require_ready_resolve(), {{ timeout|default(0)|json }} || 5000);+ {%- else %}+ require_ready_resolve();+ {%- endif %}+ function declare_resources() { // Tells the panel.js resource registry which component libraries this // bundle has already satisfied, so nothing is fetched a second time.@@ -224,16 +350,34 @@ calls it with the rendered model. if (!declared || !(declared.libs || declared.css)) { return; }- if (root.__panel_resources__ != null) {- root.__panel_resources__.declare(declared);- } else {- (root.__panel_resources_declared__ = root.__panel_resources_declared__ || []).push(declared);+ // Libraries RequireJS is loading are claimed against require_ready+ // instead of being declared, since they are not ready yet.+ const require_urls = window.requirejs ? {{ require_skip|default([])|json }} : [];+ const satisfied = [], pending = [];+ for (const lib of declared.libs || []) {+ const urls = lib.js || [];+ const by_require = urls.length > 0 && urls.every((url) => require_urls.includes(url));+ (by_require ? pending : satisfied).push(lib);+ }+ const declarations = [{libs: satisfied, css: declared.css}];+ if (pending.length > 0) {+ declarations.push({libs: pending, ready: require_ready});+ }+ for (const declaration of declarations) {+ if (root.__panel_resources__ != null) {+ if (declaration.ready != null) {+ root.__panel_resources__.claim(declaration, declaration.ready);+ } else {+ root.__panel_resources__.declare(declaration);+ }+ } else {+ (root.__panel_resources_declared__ = root.__panel_resources_declared__ || []).push(declaration);+ } } } function run_inline_js() { if ((root.Bokeh !== undefined) || (force === true)) {- declare_resources(); for (let i = 0; i < inline_js.length; i++) { try { inline_js[i].call(root, root.Bokeh);@@ -298,6 +442,10 @@ calls it with the rendered model. }); } }+ // Declared synchronously, before anything is scheduled, so a cell whose+ // output embeds while libraries are still loading finds this in place+ // instead of fetching its own second copy of each.+ declare_resources(); // Give older versions of the autoload script a head-start to ensure // they initialize before we start loading newer version. setTimeout(load_or_wait, 100)panel/io/notebook.py167 + / 16 −
@@ -6,6 +6,7 @@ import json import os+import re import sys import typing as t import uuid@@ -119,23 +120,74 @@ def push_on_root(ref: str): AUTOLOAD_NB_JS: Template = _env.get_template("autoload_panel_js.js") NB_TEMPLATE_BASE: Template = _env.get_template('nb_template.html') +def _require_stages(requirements, shim):+ """+ Groups requirements into stages so each loads after its dependencies.++ RequireJS ignores ``shim`` deps for scripts that call ``define``+ themselves (deck.gl's carto and json bundles do), so those are ordered+ via stages instead, which also gives a stage's globals time to be+ assigned before the next one runs.+ """+ pending = {+ name: {+ dep for dep in (shim.get(name, {}).get('deps') or [])+ if dep in requirements and dep != name+ }+ for name in requirements+ }+ stages, resolved = [], set()+ while pending:+ stage = [name for name, deps in pending.items() if deps <= resolved]+ if not stage:+ # A dependency cycle, so give up on ordering the remainder.+ stage = list(pending)+ stages.append(stage)+ resolved.update(stage)+ for name in stage:+ del pending[name]+ return stages+++#: Suffix of the helper modules that assign a library's browser global as+#: soon as its own module resolves.+GLOBAL_MODULE_SUFFIX = '__panel_global'++ def _autoload_js(- *, bundle, configs, requirements, exports, skip_imports, ipywidget,- reloading=False, load_timeout=5000+ *, bundle, configs, requirements, exports, error_id, skip_imports, require_skip,+ check_extension=False, ipywidget, reloading=False, load_timeout=5000 ): config = {'packages': {}, 'paths': {}, 'shim': {}} for conf in configs: for key, c in conf.items(): config[key].update(c)+ stages = _require_stages(requirements, config['shim'])+ # A deps-only shim entry is now redundant (the stages above order it+ # instead), and leaving it in makes RequireJS treat a module that calls+ # define() itself as shimmed, resolving it to undefined.+ config['shim'] = {+ name: shim for name, shim in config['shim'].items() if shim.get('exports')+ }+ stages = [+ [f'{r}{GLOBAL_MODULE_SUFFIX}' if r in exports else r for r in stage]+ for stage in stages+ ] return AUTOLOAD_NB_JS.render( bundle = bundle, force = not reloading, reloading = reloading, timeout = load_timeout,+ cdn_dist = CDN_DIST, config = config, requirements = requirements,+ require_stages = stages, exports = exports,+ global_suffix = GLOBAL_MODULE_SUFFIX,+ error_id = error_id, skip_imports = skip_imports,+ require_skip = require_skip,+ check_extension = check_extension, ipywidget = ipywidget, version = bokeh.__version__ )@@ -281,7 +333,81 @@ def mimebundle_to_html(bundle: dict[str, t.Any]) -> str: return html -def require_components():+def _cdn_url_key(url):+ """+ Normalizes a cdn url so a RequireJS path and a resource url for the+ same file compare equal, ignoring the ``.js`` suffix and protocol.+ """+ url = url.split('?')[0].split('#')[0]+ url = re.sub(r'^(?:[a-z][a-z0-9+.-]*:)?//', '', url)+ if '/npm/' in url:+ url = url.split('/npm/', 1)[1]+ elif '/' in url:+ # Drop the host, keeping the path for cdns without an /npm/ prefix.+ url = url.split('/', 1)[1]+ return url[:-3] if url.endswith('.js') else url+++def _require_covered_urls(model, model_require, resources=None):+ """+ Resolved urls of a model's scripts that RequireJS is going to load.++ 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.+ """+ paths = model_require.get('paths', {}) or {}+ path_keys = set()+ for value in paths.values():+ for url in (value if isinstance(value, (list, tuple)) else (value,)):+ if isinstance(url, str):+ path_keys.add(_cdn_url_key(url))+ if not path_keys:+ return []++ try:+ raw = list(getattr(model, '__javascript_raw__', None) or [])+ declared = list(getattr(model, '__javascript__', None) or [])+ except Exception:+ return []+ if len(raw) != len(declared):+ return []++ resolved = resources.adjust_paths(declared) if resources is not None else declared+ covered = []+ for raw_url, url in zip(raw, 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+ ):+ covered.append(url)+ return covered+++def _resolve_js_skip(skip, resources=None):+ """+ Resolves ``__js_skip__`` urls into the form the bundle emits, so they+ can be compared against it. Without this, a component whose global is+ already present would still get a second, duplicate script tag.+ """+ resolved = {}+ for name, urls in (skip or {}).items():+ if isinstance(urls, str):+ urls = [urls]+ elif not isinstance(urls, (list, tuple)):+ continue+ urls = [url for url in urls if isinstance(url, str)]+ resolved[name] = resources.adjust_paths(urls) if resources is not None else urls+ return resolved+++def require_components(resources=None): """ Returns JS snippet to load the required dependencies in the classic notebook using REQUIRE JS.@@ -290,17 +416,28 @@ def require_components(): no effect outside the classic notebook. Components should declare ``__javascript__``/``__javascript_modules__``/``__css__`` instead and let panel.io.resource_spec derive the rest.++ ``resources``, if given, resolves the ``__js_skip__`` urls so they+ match what the bundle emits. """- from ..config import config+ from ..config import config, panel_extension configs, requirements, exports = [], [], {} js_requires = []+ active_modules = tuple(+ panel_extension._imports[extension]+ for extension in panel_extension._loaded_extensions+ if extension in panel_extension._imports+ ) for qual_name, model in Model.model_class_reverse_map.items():- # We need to enable Models from Panel as well as Panel extensions- # like awesome_panel_extensions.- # The Bokeh models do not have "." in the qual_name- if "." in qual_name:+ # Third-party models have no Panel extension metadata, so retain their+ # requirements. Panel models only contribute when their extension was+ # explicitly activated in this notebook.+ module = model.__module__+ if "." in qual_name and (+ not module.startswith('panel.') or module.startswith(active_modules)+ ): js_requires.append(model) from ..reactive import ReactiveHTML@@ -312,12 +449,13 @@ def require_components(): js_requires.append(conf) skip_import = {}+ require_skip = [] for model in js_requires: if not isinstance(model, dict) and issubclass(model, ReactiveHTML) and not model._loaded(): continue if hasattr(model, '__js_skip__'):- skip_import.update(model.__js_skip__)+ skip_import.update(_resolve_js_skip(model.__js_skip__, resources)) if not (hasattr(model, '__js_require__') or isinstance(model, dict)): continue@@ -326,6 +464,9 @@ def require_components(): 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) model_exports = model_require.pop('exports', {}) if not any(model_require == config for config in configs):@@ -343,7 +484,7 @@ def require_components(): if r in model_exports: exports[r] = model_exports[r] - return configs, requirements, exports, skip_import+ return configs, requirements, exports, skip_import, require_skip class JupyterCommJSBinary(JupyterCommJS):@@ -426,37 +567,47 @@ def load_notebook( ) -> None: from IPython.display import publish_display_data + from ..config import config+ resources = INLINE if inline and not state._is_pyodide else CDN- nb_endpoint = not state._is_pyodide+ # The Jupyter extension endpoint only exists for a plain notebook kernel:+ # 'vscode'/'colab'/'ipywidgets' render through a different mimebundle+ # path (see Renderable._repr_mimebundle_) and never hit this endpoint.+ nb_endpoint = not state._is_pyodide and config.comms == 'default' # Components rendered in a later cell resolve their resources outside # any set_resource_mode block, so the notebook mode has to become the- # default rather than being scoped to the bootstrap. Inline output has- # no urls to hand out after the fact, hence the CDN.- set_default_resource_mode('cdn' if resources.mode == 'inline' else resources.mode)+ # default rather than being scoped to the bootstrap. Panel resources use+ # the Jupyter extension endpoint; Bokeh resources keep their CDN urls.+ set_default_resource_mode('cdn' if resources.mode == 'inline' else resources.mode, notebook=nb_endpoint) with set_resource_mode(resources.mode): resources = Resources.from_bokeh(resources, notebook=nb_endpoint) bundle = bundle_resources( None, resources, notebook=nb_endpoint, reloading=reloading, enable_mathjax=enable_mathjax )- configs, requirements, exports, skip_imports = require_components()+ configs, requirements, exports, skip_imports, require_skip = require_components(resources) ipywidget = 'ipywidgets_bokeh' in sys.modules+ error_id = make_id() bokeh_js = _autoload_js( bundle=bundle, configs=configs, requirements=requirements, exports=exports,+ error_id=error_id, skip_imports=skip_imports,+ require_skip=require_skip,+ check_extension=nb_endpoint, ipywidget=ipywidget, reloading=reloading, load_timeout=load_timeout ) CSS = (PANEL_DIR / '_templates' / 'jupyter.css').read_text(encoding='utf-8') shim = '<script type="esms-options">{"shimMode": true}</script>'- publish_display_data(data={'text/html': f'{shim}<style>{CSS}</style>'})+ error = f'<div id="{error_id}" role="alert" hidden></div>'+ publish_display_data(data={'text/html': f'{shim}<style>{CSS}</style>{error}'}) publish_display_data({ 'application/javascript': bokeh_js, LOAD_MIME: bokeh_js,panel/io/resource_spec.py14 + / 3 −
@@ -30,8 +30,9 @@ from ..config import config from ..util import isurl from .resources import (- Resources, component_resource_path, extension_declared, get_resource_mode,- resolve_resource_cdn, set_resource_mode,+ Resources, component_resource_path, extension_declared,+ get_notebook_resources, get_resource_mode, resolve_resource_cdn,+ set_resource_mode, ) from .state import state @@ -82,7 +83,13 @@ def _spec_mode(mode: MODES | None = None) -> tuple[str, bool]: def _resources(mode: str) -> Resources:- return Resources(mode=mode)+ # `NOTEBOOK_RESOURCES` is a process-wide default set by the notebook's+ # own bootstrap, so it isn't scoped to that notebook's document. A+ # server started from the same kernel process (e.g. `pn.serve(...,+ # threaded=True)`) must not inherit it, or every lazily-loaded resource+ # would 404 against an endpoint that server never registers.+ notebook = get_notebook_resources() and not state._is_server_session+ return Resources(mode=mode, notebook=notebook) def _parse_probe(expression: str) -> dict[str, str] | None:@@ -332,8 +339,12 @@ def resource_spec(cls: type, mode: MODES | None = None) -> dict[str, t.Any] | No if not config.lazy_resources or not _has_resources(cls): return None resolved_mode, inline_fallback = _spec_mode(mode)+ # The effective notebook default has to be part of the key too: a+ # notebook and a server started from it can share `rel_path`/`base_url`+ # while only one of them is a genuine server session (see `_resources`). key = ( cls, resolved_mode, state.rel_path, state.base_url,+ get_notebook_resources() and not state._is_server_session, tuple(getattr(cls, '__css_raw__', None) or ()), ) if key in _SPEC_CACHE:panel/io/resources.py22 + / 2 −
@@ -98,6 +98,7 @@ def parse_template(*args, **kwargs): # Handle serving of the panel extension before session is loaded RESOURCE_MODE: MODES = 'server'+NOTEBOOK_RESOURCES = False PANEL_DIR = Path(__file__).parent.parent DIST_DIR = PANEL_DIR / 'dist' BUNDLE_DIR = DIST_DIR / 'bundled'@@ -200,7 +201,11 @@ def get_resource_mode() -> MODES: """ return RESOURCE_MODE -def set_default_resource_mode(mode: MODES):+def get_notebook_resources() -> bool:+ """Whether resource urls should use the Jupyter extension endpoint."""+ return NOTEBOOK_RESOURCES++def set_default_resource_mode(mode: MODES, *, notebook: bool = False): """ Sets the mode urls are resolved for outside a set_resource_mode block. @@ -212,8 +217,9 @@ def set_default_resource_mode(mode: MODES): context manager either, because components created in a later cell resolve their resources long after ``pn.extension()`` returned. """- global RESOURCE_MODE+ global NOTEBOOK_RESOURCES, RESOURCE_MODE RESOURCE_MODE = mode+ NOTEBOOK_RESOURCES = notebook def use_cdn() -> bool: return _settings.resources(default="server") != 'server' or state._is_pyodide@@ -844,6 +850,20 @@ def adjust_paths(self, resources): resource = f'{self.root_url}{resource}' if resource.endswith('.css') and not resource.startswith(('http:', 'https:')): resource += version_suffix+ if self.notebook:+ # The render endpoint sets `rel_path` to its own+ # `panel-preview` root already, so building on `base_url`+ # there would double it. nbclassic never sets `rel_path`,+ # so `base_url` is the raw server root there instead.+ if state.rel_path and state.rel_path.rstrip('/').endswith('panel-preview'):+ endpoint = f"{state.rel_path.rstrip('/')}/static/extensions/panel/"+ else:+ base_url = state.base_url.removesuffix('nbclassic/')+ endpoint = f'{base_url}panel-preview/static/extensions/panel/'+ if resource.startswith(CDN_DIST):+ resource = endpoint + resource.removeprefix(CDN_DIST)+ elif resource.startswith(LOCAL_DIST):+ resource = endpoint + resource.removeprefix(LOCAL_DIST) new_resources.append(resource) return new_resources panel/io/state.py14 + / 0 −
@@ -374,6 +374,20 @@ def _is_launching(self) -> bool: return False return not bool(curdoc.session_context.server_context.sessions) + @property+ def _is_server_session(self) -> bool:+ """+ Whether the current document belongs to a genuine served session,+ as opposed to a notebook comm-rendered document or the Jupyter+ extension's own render endpoint (both leave ``server_context``+ unset). Used to keep notebook-only resource defaults from leaking+ into a server started from within that same notebook process.+ """+ curdoc = self.curdoc+ return bool(+ curdoc and curdoc.session_context and curdoc.session_context.server_context+ )+ @property def _is_pyodide(self) -> bool: return '_pyodide' in sys.modulespanel/models/deckgl.py15 + / 1 −
@@ -70,11 +70,25 @@ def __js_skip__(cls): "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"},+ '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"]} } }panel/models/deckgl.ts1 + / 1 −
@@ -353,7 +353,7 @@ export class DeckGLPlot extends LayoutDOM { this.prototype.default_view = DeckGLPlotView this.define<DeckGLPlot.Props>(({Any, List, Str, Ref}) => ({- data: [ Any ],+ data: [ Any, {} ], data_sources: [ List(Ref(ColumnDataSource)), [] ], clickState: [ Any, {} ], hoverState: [ Any, {} ],panel/models/echarts.py3 + / 1 −
@@ -52,7 +52,9 @@ def __js_skip__(cls): "echarts": f"{config.npm_cdn}/echarts@{ECHARTS_VERSION}/dist/echarts.min", "echarts-gl": f"{config.npm_cdn}/echarts-gl@2.0.9/dist/echarts-gl.min" },- 'exports': {}+ # echarts-gl registers itself with echarts and exposes nothing the view+ # reads, so only echarts needs a global.+ 'exports': {'echarts': 'echarts'} } data = Nullable(Dict(String, Any))panel/models/resources.ts73 + / 5 −
@@ -70,12 +70,51 @@ function existing_urls(selector: string, attr: "src" | "href"): Set<string> { return urls } +const JUPYTER_EXTENSION_PATH = "/panel-preview/static/extensions/panel/"++/**+ * Rewrites a failed Jupyter-extension-endpoint url to its CDN equivalent,+ * mirroring the eager bootstrap's fallback in autoload_panel_js.js. Only+ * tried once per element, and only when the bootstrap has published the+ * CDN base (it hasn't in server/served-app contexts, where this endpoint+ * never appears in the first place).+ */+function fallback_to_cdn(el: HTMLScriptElement | HTMLLinkElement, url: string): string | null {+ const index = url.indexOf(JUPYTER_EXTENSION_PATH)+ const cdn_dist = (globalThis as any).__panel_cdn_dist__+ if (index === -1 || typeof cdn_dist !== "string" || el.dataset.panelCdnFallback != null) {+ return null+ }+ el.dataset.panelCdnFallback = ""+ return cdn_dist + url.slice(index + JUPYTER_EXTENSION_PATH.length)+}+ function inject(el: HTMLScriptElement | HTMLLinkElement): Promise<void> { return new Promise<void>((resolve, reject) => {- el.addEventListener("load", () => resolve(), {once: true})- el.addEventListener("error", () => reject(- new Error(`Failed to load ${(el as HTMLScriptElement).src || (el as HTMLLinkElement).href}`),- ), {once: true})+ const attempt = (element: HTMLScriptElement | HTMLLinkElement) => {+ element.addEventListener("load", () => resolve(), {once: true})+ element.addEventListener("error", () => {+ const url = (element as HTMLScriptElement).src || (element as HTMLLinkElement).href+ const fallback = fallback_to_cdn(element, url)+ if (fallback != null) {+ element.remove()+ if (element instanceof HTMLLinkElement) {+ element.href = fallback+ } else {+ element.src = fallback+ }+ attempt(element)+ document.head.appendChild(element)+ return+ }+ if (url.includes(JUPYTER_EXTENSION_PATH)) {+ const global = globalThis as any+ global.__panel_jupyter_extension_error__?.()+ }+ reject(new Error(`Failed to load ${url}`))+ }, {once: true})+ }+ attempt(el) document.head.appendChild(el) }) }@@ -210,6 +249,28 @@ export class ResourceRegistry { } } + /**+ * Records that a loader outside the registry (RequireJS, in the classic+ * notebook) is already fetching these libraries. Unlike `declare`, this+ * does not mark them ready immediately: `await_resources` waits on+ * `ready` instead, so a view doesn't read an unassigned global.+ */+ claim(declared: {libs?: LibSpec[]}, ready: Promise<void>): void {+ for (const lib of declared.libs ?? []) {+ if (lib == null || lib.name == null) {+ continue+ }+ this.specs.set(lib.name, lib)+ this.libs.set(lib.name, ready)+ for (const url of lib.js ?? []) {+ this.urls.set(url_key(url), ready)+ }+ for (const {url} of lib.modules ?? []) {+ this.urls.set(url_key(url), ready)+ }+ }+ }+ /** * Whether a library is already available without loading anything. *@@ -469,7 +530,14 @@ function install(): ResourceRegistry { if (Array.isArray(queued)) { global.__panel_resources_declared__ = [] for (const declared of queued) {- registry.declare(declared)+ // A queued entry carrying `ready` is a claim: some other loader, i.e.+ // the notebook's RequireJS, is fetching those libraries already.+ const ready = declared?.ready+ if (ready != null && typeof ready.then === "function") {+ registry.claim(declared, ready)+ } else {+ registry.declare(declared)+ } } } return registrypanel/package-lock.json4 + / 0 −
@@ -473,6 +473,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT",+ "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1",@@ -686,6 +687,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT",+ "peer": true, "bin": { "acorn": "bin/acorn" },@@ -863,6 +865,7 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT",+ "peer": true, "workspaces": [ "packages/*" ],@@ -1561,6 +1564,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", "license": "MIT",+ "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact"panel/tests/conftest.py4 + / 1 −
@@ -88,7 +88,10 @@ def get_default_port(): def start_jupyter(): global JUPYTER_PORT, JUPYTER_PROCESS- args = ['jupyter', 'server', '--port', str(JUPYTER_PORT), "--NotebookApp.token=''"]+ args = [+ 'jupyter', 'server', '--port', str(JUPYTER_PORT), "--NotebookApp.token=''",+ "--ServerApp.jpserver_extensions={'nbclassic': True}",+ ] JUPYTER_PROCESS = process = Popen(args, stdout=PIPE, stderr=PIPE, bufsize=1, encoding='utf-8') deadline = time.monotonic() + JUPYTER_TIMEOUT while True:panel/tests/io/test_notebook.py210 + / 8 −
@@ -1,3 +1,5 @@+import re+ import pytest pytest.importorskip("IPython")@@ -6,12 +8,17 @@ from panel.config import config, panel_extension from panel.io import resources as resources_module-from panel.io.notebook import ipywidget, load_notebook, replace_inline_css-from panel.io.resource_spec import resource_spec+from panel.io.notebook import (+ LOAD_MIME, 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, ) from panel.layout import Column+from panel.models.echarts import ECharts+from panel.models.perspective import Perspective from panel.models.tabulator import DataTabulator from panel.pane import Str from panel.widgets import TextEditor@@ -36,13 +43,19 @@ def notebook_bootstrap(): """ from bokeh.io.state import curstate state, mode = curstate(), resources_module.RESOURCE_MODE+ notebook_resources = resources_module.NOTEBOOK_RESOURCES notebook, notebook_type = state.notebook, state.notebook_type+ # `_SPEC_CACHE` is keyed in part on `RESOURCE_MODE`/`NOTEBOOK_RESOURCES`,+ # so it must not leak a spec between this bootstrap and other tests.+ _SPEC_CACHE.clear() try: load_notebook(inline=True) yield finally: resources_module.RESOURCE_MODE = mode+ resources_module.NOTEBOOK_RESOURCES = notebook_resources state._notebook, state._notebook_type = notebook, notebook_type+ _SPEC_CACHE.clear() @jb_available@@ -100,20 +113,160 @@ def test_notebook_inline_css_stylesheets(nb_loaded): assert isinstance(stylesheet, InlineStyleSheet) -def test_notebook_resources_resolve_absolutely(notebook_bootstrap):+def test_notebook_inline_resources_shadow_amd_globals(monkeypatch, notebook_bootstrap):+ """+ Inline UMD bundles must assign their browser globals on RequireJS pages.++ See https://github.com/holoviz/panel/issues/8750.+ """+ published = []++ def publish_display_data(data, **kwargs):+ published.append(data)++ monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++ load_notebook(inline=True)++ bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+ assert 'function(Bokeh, define, module, exports)' in bootstrap+ assert 'window.requirejs.config' in bootstrap+++def test_require_components_only_includes_active_panel_extensions(monkeypatch):+ """Classic notebook RequireJS setup must not load inactive extensions."""+ from bokeh.core.has_props import _default_resolver++ monkeypatch.setattr(panel_extension, '_loaded_extensions', ['echarts'])+ # model_class_reverse_map is derived from Bokeh's resolver rather than being+ # a plain dict, so registering has to go through the resolver. The autouse+ # module_cleanup fixture unregisters Panel's models before every test and+ # they are only registered when their class is defined, which has already+ # happened, so re-registering here is what puts them back.+ monkeypatch.setitem(+ _default_resolver._known_models, 'panel.models.echarts.ECharts', ECharts+ )+ monkeypatch.setitem(+ _default_resolver._known_models, 'panel.models.perspective.Perspective', Perspective+ )++ configs, requirements, *_ = require_components()++ assert 'echarts' in requirements+ assert not any(name.startswith('perspective') for name in requirements)+ assert any('echarts' in config['paths'] for config in configs)+++def test_notebook_endpoint_alert_stays_in_notebook_output(monkeypatch, notebook_bootstrap):+ """+ Endpoint failures must update Panel's output rather than the notebook UI.+ """+ published = []++ def publish_display_data(data, **kwargs):+ published.append(data)++ monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++ load_notebook(inline=False)++ bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+ html = next(data['text/html'] for data in published if 'text/html' in data)+ error_id = re.search(r'<div id="([^"]+)" role="alert" hidden>', html).group(1)+ assert f'document.getElementById("{error_id}")' in bootstrap+ assert '(document.body || document.documentElement)' not in bootstrap+ assert 'function fallback_to_cdn(element, url, attribute, parent)' in bootstrap+ assert 'element.dataset.panelCdnFallback != null' in bootstrap+ assert 'const CDN_DIST = "https://cdn.holoviz.org/panel/' in bootstrap+++def test_notebook_vscode_resources_use_cdn(monkeypatch, notebook_bootstrap):+ """+ VS Code has no Jupyter extension endpoint to serve Panel resources.+ """+ published = []++ def publish_display_data(data, **kwargs):+ published.append(data)++ monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++ with config.set(comms='vscode'):+ load_notebook(inline=False)++ bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+ js_urls = next(line for line in bootstrap.splitlines() if 'const js_urls' in line)+ assert '/panel-preview/static/extensions/panel/' not in js_urls+ assert 'https://cdn.holoviz.org/panel/' in js_urls+++def test_notebook_ipywidgets_resources_use_cdn(monkeypatch, notebook_bootstrap):+ """+ ipywidgets mode (Voila and similar) has no Jupyter extension endpoint+ either: only a plain notebook kernel (``comms == 'default'``) does.+ """+ published = []++ def publish_display_data(data, **kwargs):+ published.append(data)++ monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++ with config.set(comms='ipywidgets'):+ load_notebook(inline=False)++ bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+ js_urls = next(line for line in bootstrap.splitlines() if 'const js_urls' in line)+ assert '/panel-preview/static/extensions/panel/' not in js_urls+ assert 'https://cdn.holoviz.org/panel/' in js_urls+++def test_notebook_checks_extension_only_for_default_comms(monkeypatch):+ """+ The proactive liveness check only makes sense where the endpoint is+ actually expected to exist, i.e. a plain notebook kernel.+ """+ from bokeh.io.state import curstate++ published = []++ def publish_display_data(data, **kwargs):+ published.append(data)++ monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++ for comms in ('default', 'vscode', 'colab', 'ipywidgets'):+ published.clear()+ bk_state, mode = curstate(), resources_module.RESOURCE_MODE+ notebook_resources = resources_module.NOTEBOOK_RESOURCES+ notebook, notebook_type = bk_state.notebook, bk_state.notebook_type+ try:+ with config.set(comms=comms):+ load_notebook(inline=False)+ bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+ checks = 'panel.min.js", {method: "HEAD"}' in bootstrap+ assert checks == (comms == 'default'), comms+ finally:+ resources_module.RESOURCE_MODE = mode+ resources_module.NOTEBOOK_RESOURCES = notebook_resources+ bk_state._notebook, bk_state._notebook_type = notebook, notebook_type+ _SPEC_CACHE.clear()+++def test_notebook_resources_use_jupyter_extension_endpoint(notebook_bootstrap): """ A component rendered in a later cell builds its specification outside- any resource mode block, and the notebook page cannot resolve a url- into the static endpoint Panel serves for an application.+ any resource mode block, so it has to preserve the Jupyter extension+ endpoint selected by the notebook bootstrap. """ spec = resource_spec(DataTabulator) urls = [url for lib in spec['libs'] for url in lib['js']] + spec['css'] assert urls- assert all(url.startswith('http') for url in urls)+ assert all(url.startswith('/panel-preview/static/extensions/panel/') for url in urls) -def test_notebook_dynamic_component_resources_resolve_absolutely(+def test_notebook_dynamic_component_resources_use_jupyter_extension_endpoint( nb_loaded, notebook_bootstrap ): column = Column()@@ -125,7 +278,56 @@ def test_notebook_dynamic_component_resources_resolve_absolutely( urls = [url for lib in model.external_resources['libs'] for url in lib['js']] assert urls- assert all(url.startswith('http') for url in urls)+ assert all(url.startswith('/panel-preview/static/extensions/panel/') for url in urls)+++@pytest.mark.filterwarnings(+ "ignore:Attempted to send message over Jupyter Comm.*:UserWarning"+)+def test_notebook_resources_do_not_reuse_spec_cached_before_bootstrap(nb_loaded):+ """+ Regression test for a CI failure under xdist: a spec cached by an+ earlier, unrelated test under the same resolved mode was handed back+ unchanged to a notebook test, dropping the Jupyter extension endpoint.+ ``resource_spec``'s cache key must include the effective notebook+ default, not just the resolved mode.++ The filter is unrelated to what this checks: Python only raises that+ warning the first time it's hit in the process, so whether it surfaces+ here depends on what other tests already ran.+ """+ from bokeh.io.state import curstate+ state, mode = curstate(), resources_module.RESOURCE_MODE+ notebook_resources = resources_module.NOTEBOOK_RESOURCES+ notebook, notebook_type = state.notebook, state.notebook_type+ try:+ # Simulates the "earlier, unrelated test": mode 'cdn', no notebook.+ resources_module.RESOURCE_MODE = 'cdn'+ widget = TextEditor()+ outside_notebook_urls = [+ url for lib in (widget.get_root().external_resources or {}).get('libs', [])+ for url in lib['js']+ ]+ assert not any('panel-preview' in url for url in outside_notebook_urls)++ # The notebook bootstrap now runs, selecting that same 'cdn' mode.+ load_notebook(inline=True)++ column = Column()+ column._repr_mimebundle_()+ editor = TextEditor()+ column.append(editor)++ (model, _) = list(editor._models.values())[0]+ urls = [url for lib in model.external_resources['libs'] for url in lib['js']]++ assert urls+ assert all(url.startswith('/panel-preview/static/extensions/panel/') for url in urls)+ finally:+ resources_module.RESOURCE_MODE = mode+ resources_module.NOTEBOOK_RESOURCES = notebook_resources+ state._notebook, state._notebook_type = notebook, notebook_type+ _SPEC_CACHE.clear() def test_replace_inline_css_ignores_version_query():panel/tests/io/test_resource_spec.py40 + / 0 −
@@ -1,9 +1,11 @@ import pytest from bokeh.model import Model+from bokeh.server.contexts import BokehSessionContext from panel.config import config, panel_extension as extension from panel.custom import JSComponent+from panel.io import resources as resources_module from panel.io.resource_spec import ( SPEC_VERSION, declared_specs, lazy_load_available, resource_spec, )@@ -376,3 +378,41 @@ def test_resource_spec_respects_rel_path(document): state.base_url = '/' state.rel_path = '' assert all(url.startswith('../static/') for url in _spec_urls(spec))+++def test_resource_spec_ignores_stale_notebook_default_in_server_session(document):+ """+ A server started from within a notebook's kernel process (``pn.serve``,+ commonly with ``threaded=True``) must not inherit the notebook's+ process-wide resource default: a plain served app never registers the+ `panel-preview` endpoint, so every lazily-loaded resource would 404.+ """+ server_doc = document+ session_context = BokehSessionContext('test-session', object(), server_doc)+ server_doc._session_context = lambda: session_context++ old_notebook_resources = resources_module.NOTEBOOK_RESOURCES+ resources_module.NOTEBOOK_RESOURCES = True+ try:+ assert state._is_server_session is False # no curdoc bound yet++ with set_curdoc(server_doc):+ assert state._is_server_session is True+ spec = resource_spec(DataTabulator, 'cdn')+ assert not any(+ 'panel-preview/static/extensions/panel' in url for url in _spec_urls(spec)+ )++ # The same class, resolved for a plain notebook comm document (no+ # session context at all), must still get the endpoint: the fix+ # narrowly targets genuine server sessions, not every document.+ from bokeh.document import Document+ notebook_doc = Document()+ with set_curdoc(notebook_doc):+ assert state._is_server_session is False+ spec = resource_spec(DataTabulator, 'cdn')+ assert all(+ 'panel-preview/static/extensions/panel' in url for url in _spec_urls(spec)+ )+ finally:+ resources_module.NOTEBOOK_RESOURCES = old_notebook_resourcespanel/tests/io/test_resources.py40 + / 0 −
@@ -113,6 +113,46 @@ def test_resources_cdn(): f'https://cdn.bokeh.org/bokeh/{bk_prefix}/bokeh-mathjax-{bokeh_version}.min.js', ] ++def test_notebook_resources_respect_jupyterhub_base_url():+ resource = f'{CDN_DIST}bundled/datatabulator/tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+ with edit_readonly(state):+ state.base_url = '/user/alice/'+ try:+ resolved = Resources(mode='cdn', notebook=True).adjust_paths([resource])+ finally:+ with edit_readonly(state):+ state.base_url = '/'++ assert resolved == [+ f'/user/alice/panel-preview/static/extensions/panel/bundled/datatabulator/'+ f'tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+ ]+++def test_notebook_resources_do_not_double_render_endpoint_root():+ """+ The render endpoint sets `rel_path` to its own `panel-preview` root+ already, so it must not be appended a second time on top of+ `base_url`, which the render endpoint also includes it in.+ """+ resource = f'{CDN_DIST}bundled/datatabulator/tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+ with edit_readonly(state):+ state.base_url = '/user/alice/panel-preview/'+ state.rel_path = '/user/alice/panel-preview'+ try:+ resolved = Resources(mode='cdn', notebook=True).adjust_paths([resource])+ finally:+ with edit_readonly(state):+ state.base_url = '/'+ state.rel_path = ''++ assert resolved == [+ f'/user/alice/panel-preview/static/extensions/panel/bundled/datatabulator/'+ f'tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+ ]++ def test_resources_server_absolute(): resources = Resources(mode='server', absolute=True, minified=True) assert resources.js_raw == ['Bokeh.set_log_level("info");']panel/tests/ui/io/nbclassic/components.ipynbadded74 + / 0 −
@@ -0,0 +1,74 @@+{+ "cells": [+ {+ "cell_type": "code",+ "execution_count": null,+ "id": "b665ec80-285d-4062-8c4f-48e1f63db97e",+ "metadata": {},+ "outputs": [],+ "source": [+ "import pandas as pd\n",+ "import panel as pn\n",+ "\n",+ "pn.extension(\n",+ " 'codeeditor', 'deckgl', 'echarts', 'filedropper', 'jsoneditor',\n",+ " 'katex', 'perspective', 'tabulator', 'terminal', 'vega',\n",+ " comms='default', inline=False,\n",+ ")\n"+ ]+ },+ {+ "cell_type": "code",+ "execution_count": null,+ "id": "c239d395-16df-4291-8b0c-9ff831e2235e",+ "metadata": {},+ "outputs": [],+ "source": [+ "button = pn.widgets.Button(name='Increment')\n",+ "counter = pn.pane.Markdown('0', css_classes=['nbclassic-counter'])\n",+ "button.on_click(lambda event: setattr(counter, 'object', str(int(counter.object) + 1)))\n",+ "pn.Row(button, counter)\n"+ ]+ },+ {+ "cell_type": "code",+ "execution_count": null,+ "id": "3142bbe9-c976-4a82-8b88-defebd0db5d3",+ "metadata": {},+ "outputs": [],+ "source": [+ "data = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})\n",+ "pn.Column(\n",+ " pn.widgets.Tabulator(data, height=160),\n",+ " pn.pane.Perspective(data, height=200),\n",+ " pn.widgets.FileDropper(height=100),\n",+ " pn.widgets.CodeEditor(value='print(\"Panel\")', height=100),\n",+ " pn.widgets.JSONEditor(value={'answer': 42}, height=160),\n",+ " pn.pane.ECharts({'xAxis': {'data': ['A', 'B']}, 'yAxis': {}, 'series': [{'type': 'bar', 'data': [1, 2]}]}, height=180),\n",+ " pn.pane.Vega({\n",+ " '$schema': 'https://vega.github.io/schema/vega-lite/v5.json',\n",+ " 'mark': 'bar',\n",+ " 'data': {\n",+ " 'values': [{'x': 'A', 'y': 1}]\n",+ " },\n",+ " 'encoding': {\n",+ " 'x': {'field': 'x', 'type': 'nominal'},\n",+ " 'y': {'field': 'y', 'type': 'quantitative'}\n",+ " }\n",+ " }, height=180),\n",+ " pn.pane.DeckGL({'initialViewState': {'longitude': 0, 'latitude': 0, 'zoom': 1}, 'layers': []}, height=180),\n",+ " pn.widgets.Terminal(height=100),\n",+ " pn.pane.LaTeX(r'E = mc^2'),\n",+ ")\n"+ ]+ }+ ],+ "metadata": {+ "language_info": {+ "name": "python",+ "pygments_lexer": "ipython3"+ }+ },+ "nbformat": 4,+ "nbformat_minor": 5+}panel/tests/ui/io/test_lazy_resources.py25 + / 0 −
@@ -389,3 +389,28 @@ def app(): assert _errors(msgs) == [] finally: pn.config.lazy_resources = True+++def test_lazy_component_ignores_stale_notebook_default(page):+ """+ A server started from a notebook's kernel process (``pn.serve``,+ commonly with ``threaded=True``, exactly how ``serve_component``+ starts one here) must not inherit that notebook's resource default:+ it never registers the `panel-preview` endpoint, so every+ lazily-loaded resource would 404.+ """+ from panel.io import resources as resources_module+ old_notebook_resources = resources_module.NOTEBOOK_RESOURCES+ resources_module.NOTEBOOK_RESOURCES = True+ try:+ def app():+ extension()+ _tabulator().servable()++ msgs, _ = serve_component(page, app)++ expect(page.locator('.pnx-tabulator.tabulator')).to_have_count(1, timeout=20000)+ assert _errors(msgs) == []+ assert _script_count(page, 'panel-preview') == 0+ finally:+ resources_module.NOTEBOOK_RESOURCES = old_notebook_resourcespanel/tests/ui/io/test_nbclassic.pyadded156 + / 0 −
@@ -0,0 +1,156 @@+import pytest+import requests++pytest.importorskip("playwright")++from playwright.sync_api import expect++pytestmark = [pytest.mark.ui, pytest.mark.jupyter]+++@pytest.fixture+def nbclassic_server(jupyter_preview):+ """Use nbclassic as an extension of the shared Jupyter Server."""+ host, _ = jupyter_preview.split('/panel-preview/', 1)+ return f'{host}/nbclassic'+++def run_notebook(page, nbclassic_server, notebook_name, cells):+ """Open a notebook in nbclassic and run every cell through its UI."""+ host = nbclassic_server+ api_host = host.removesuffix('/nbclassic')+ notebook = {+ 'cells': [+ {+ 'cell_type': 'code',+ 'execution_count': None,+ 'metadata': {},+ 'outputs': [],+ 'source': cell.splitlines(keepends=True),+ }+ for cell in cells+ ],+ 'metadata': {+ 'kernelspec': {'display_name': 'Python 3', 'language': 'python', 'name': 'python3'},+ 'language_info': {'name': 'python'},+ },+ 'nbformat': 4,+ 'nbformat_minor': 5,+ }+ session = requests.Session()+ session.get(f'{host}/tree', timeout=10).raise_for_status()+ response = session.put(+ f'{api_host}/api/contents/{notebook_name}.ipynb',+ json={'type': 'notebook', 'format': 'json', 'content': notebook},+ headers={'X-XSRFToken': session.cookies['_xsrf']},+ timeout=10,+ )+ response.raise_for_status()+ page.goto(f'{host}/notebooks/{notebook_name}.ipynb')+ expect(page.locator('#notebook-container .code_cell').first).to_be_visible()+ page.wait_for_function(+ "window.Jupyter?.notebook?._fully_loaded && "+ "window.Jupyter.notebook.kernel && !window.Jupyter.notebook.kernel_busy"+ )+ page.evaluate('Jupyter.notebook.execute_all_cells()')+ page.wait_for_function(+ 'Jupyter.notebook.get_cells().every((cell) => cell.input_prompt_number != null)'+ )+ page.wait_for_function("window.Jupyter && !window.Jupyter.notebook.kernel_busy")+ errors = page.locator('.output_error').all_inner_texts()+ assert not errors, '\n'.join(errors)+++@pytest.mark.parametrize(+ ('extension', 'component', 'selector'),+ [+ ('tabulator', "pn.widgets.Tabulator(pd.DataFrame({'x': [1, 2, 3]}), height=160)", '.pnx-tabulator.tabulator'),+ ('perspective', "pn.pane.Perspective(pd.DataFrame({'x': [1, 2, 3]}), height=200)", 'perspective-viewer'),+ ('filedropper', 'pn.widgets.FileDropper(height=100)', '.filepond--root'),+ ('codeeditor', "pn.widgets.CodeEditor(value='print(1)', height=100)", '.ace_editor'),+ ('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,+ ),+ ),+ ('terminal', 'pn.widgets.Terminal(height=100)', '.xterm'),+ ('katex', "pn.pane.LaTeX(r'$E = mc^2$')", '.katex'),+ ],+ ids=[+ 'tabulator', 'perspective', 'filedropper', 'codeeditor', 'jsoneditor',+ 'echarts', 'vega', 'deckgl', 'terminal', 'katex',+ ],+)+def test_nbclassic_component_resources(page, nbclassic_server, extension, component, selector):+ """Load each bundled component independently through nbclassic resources."""+ 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, [+ 'import pandas as pd\nimport panel as pn',+ f"pn.extension('{extension}', comms='default', inline=False)",+ 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+++def test_nbclassic_component_comm(page, nbclassic_server):+ """Verify that a nbclassic browser event reaches Panel's notebook comm."""+ counter_cell = (+ "button = pn.widgets.Button(name='Increment')\n"+ "counter = pn.pane.Markdown('0', css_classes=['nbclassic-counter'])\n"+ "button.on_click(lambda event: setattr(counter, 'object', str(int(counter.object) + 1)))\n"+ "pn.Row(button, counter)"+ )+ run_notebook(page, nbclassic_server, 'component_comm', [+ 'import panel as pn\npn.extension(comms="default", inline=False)',+ counter_cell,+ ])++ page.get_by_role('button', name='Increment').click()+ expect(page.locator('.nbclassic-counter')).to_have_text('1')+++def test_nbclassic_warns_when_extension_missing(page, nbclassic_server):+ """+ The proactive liveness check must warn even when nothing else on the+ page happens to request a panel-preview resource that would otherwise+ trigger the reactive error path.+ """+ page.route(+ '**/panel-preview/static/extensions/panel/**',+ lambda route: route.fulfill(status=404, body='Not Found'),+ )+ run_notebook(page, nbclassic_server, 'extensioncheck', [+ "import panel as pn\npn.extension(comms='default', inline=False)",+ ])++ alert = page.locator('.output_area [role="alert"]')+ expect(alert).to_be_visible(timeout=15000)+ expect(alert).to_contain_text('Jupyter server extension')pixi.toml2 + / 0 −
@@ -230,6 +230,8 @@ playwright-python = "1.61.*" pytest-playwright = "*" pytest-asyncio = "*" jupyter_server = "*"+nbclassic = "*"+notebook = "*" esbuild = "*" packaging = "*"