From 8e167ffd2d479dc6b33aec6eacae115a4635746b Mon Sep 17 00:00:00 2001 From: Felix Mayr <_@flxmr.de> Date: Wed, 22 May 2024 00:30:09 +0200 Subject: [PATCH 1/2] fix dependency handling for WASM (proper resolution of requirements.txt and custom location wheels). enable packaging resources used by the python app --- panel/_templates/pyodide_worker.js | 44 ++--- panel/command/convert.py | 16 +- panel/io/convert.py | 288 +++++++++++++++++++++-------- 3 files changed, 246 insertions(+), 102 deletions(-) diff --git a/panel/_templates/pyodide_worker.js b/panel/_templates/pyodide_worker.js index 40b64cd2a1..88e9589a50 100644 --- a/panel/_templates/pyodide_worker.js +++ b/panel/_templates/pyodide_worker.js @@ -13,29 +13,29 @@ async function startApplication() { self.postMessage({type: 'status', msg: 'Loading pyodide'}) self.pyodide = await loadPyodide(); self.pyodide.globals.set("sendPatch", sendPatch); - console.log("Loaded!"); + console.log("Loaded pyodide!"); + const data_archives = [{{ data_archives }}]; + for (const archive of data_archives) { + let zipResponse = await fetch(archive); + let zipBinary = await zipResponse.arrayBuffer(); + self.postMessage({type: 'status', msg: `Unpacking ${archive}`}) + self.pyodide.unpackArchive(zipBinary, "zip"); + } await self.pyodide.loadPackage("micropip"); - const env_spec = [{{ env_spec }}] - for (const pkg of env_spec) { - let pkg_name; - if (pkg.endsWith('.whl')) { - pkg_name = pkg.split('/').slice(-1)[0].split('-')[0] - } else { - pkg_name = pkg - } - self.postMessage({type: 'status', msg: `Installing ${pkg_name}`}) - try { - await self.pyodide.runPythonAsync(` - import micropip - await micropip.install('${pkg}'); - `); - } catch(e) { - console.log(e) - self.postMessage({ - type: 'status', - msg: `Error while installing ${pkg_name}` - }); - } + self.postMessage({type: 'status', msg: `Installing packages`}); + // a finegrained approach installing dependencies one after another with status updates was implemented previously + // it somehow did not resolve previously installed dependencies correctly + try { + await pyodide.runPythonAsync(` + import micropip + await micropip.install([{{ env_spec }}]); + `); + } catch(e) { + console.log(e) + self.postMessage({ + type: 'status', + msg: `Error while installing packages` + }); } console.log("Packages loaded!"); self.postMessage({type: 'status', msg: 'Executing code'}) diff --git a/panel/command/convert.py b/panel/command/convert.py index 58cbae7525..aec7853470 100644 --- a/panel/command/convert.py +++ b/panel/command/convert.py @@ -28,7 +28,7 @@ class Convert(Subcommand): ('--to', dict( action = 'store', type = str, - help = "The format to convert to, one of 'pyodide' (default), 'pyodide-worker' or 'pyscript'", + help = "The format to convert to, one of 'pyodide' (default), 'pyodide-worker', 'pyscript' or 'pyscript-worker'", default = 'pyodide' )), ('--compiled', dict( @@ -70,6 +70,12 @@ class Convert(Subcommand): action = 'store_false', help = "Whether to disable patching http requests using the pyodide-http library." )), + ('--resources', dict( + nargs = '+', + help = ( + "Files to pack for distribution with the app. Does only support files located in the directory of the main panel app (or in subdirectories below)." + ) + )), ('--watch', dict( action = 'store_true', help = "Watch the files" @@ -112,10 +118,10 @@ def invoke(self, args: argparse.Namespace) -> None: try: convert_apps( files, dest_path=args.out, runtime=runtime, requirements=requirements, - prerender=not args.skip_embed, build_index=index, build_pwa=args.pwa, - title=args.title, max_workers=args.num_procs, - http_patch=not args.disable_http_patch, compiled=args.compiled, - verbose=True + resources=args.resources, prerender=not args.skip_embed, + build_index=index, build_pwa=args.pwa, title=args.title, + max_workers=args.num_procs, http_patch=not args.disable_http_patch, + compiled=args.compiled, verbose=True ) except KeyboardInterrupt: print("Aborted while building docs.") # noqa: T201 diff --git a/panel/io/convert.py b/panel/io/convert.py index 7b5dec7555..37082f4ade 100644 --- a/panel/io/convert.py +++ b/panel/io/convert.py @@ -8,6 +8,8 @@ import uuid from typing import IO, Any, Literal +from urllib.parse import urlparse +from zipfile import ZipFile import bokeh @@ -35,13 +37,13 @@ PWA_MANIFEST_TEMPLATE = _pn_env.get_template('site.webmanifest') SERVICE_WORKER_TEMPLATE = _pn_env.get_template('serviceWorker.js') WEB_WORKER_TEMPLATE = _pn_env.get_template('pyodide_worker.js') -WORKER_HANDLER_TEMPLATE = _pn_env.get_template('pyodide_handler.js') +WORKER_HANDLER_TEMPLATE = _pn_env.get_template('pyodide_handler.js') PANEL_ROOT = pathlib.Path(__file__).parent.parent BOKEH_VERSION = base_version(bokeh.__version__) PY_VERSION = base_version(__version__) PYODIDE_VERSION = 'v0.25.0' -PYSCRIPT_VERSION = '2024.2.1' +PYSCRIPT_VERSION = '2024.3.2' PANEL_LOCAL_WHL = DIST_DIR / 'wheels' / f'panel-{__version__.replace("-dirty", "")}-py3-none-any.whl' BOKEH_LOCAL_WHL = DIST_DIR / 'wheels' / f'bokeh-{BOKEH_VERSION}-py3-none-any.whl' PANEL_CDN_WHL = f'{CDN_DIST}wheels/panel-{PY_VERSION}-py3-none-any.whl' @@ -54,8 +56,6 @@ PYODIDE_JS = f'' PYODIDE_PYC_JS = f'' -MINIMUM_VERSIONS = {} - ICON_DIR = DIST_DIR / 'images' PWA_IMAGES = [ ICON_DIR / 'favicon.ico', @@ -87,6 +87,11 @@ """ + @dataclasses.dataclass class Request: - headers : dict - cookies : dict - arguments : dict + headers: dict + cookies: dict + arguments: dict + + +@dataclasses.dataclass +class DummyRequirement: + url: str + name: str = 'DUMMY' + specifier: str = '' class MockSessionContext(SessionContext): @@ -143,7 +156,6 @@ def request(self): return Request(headers={}, cookies={}, arguments={}) - def make_index(files, title=None, manifest=True): if manifest: manifest = 'site.webmanifest' @@ -157,6 +169,7 @@ def make_index(files, title=None, manifest=True): favicon=favicon, title=title, PANEL_CDN=CDN_DIST ) + def build_pwa_manifest(files, title=None, **kwargs): if len(files) > 1: title = title or 'Panel Applications' @@ -170,16 +183,116 @@ def build_pwa_manifest(files, title=None, **kwargs): **kwargs ) + +def collect_python_requirements( + main_app: str | os.PathLike, + requirements: list[str] | Literal['auto'] | os.PathLike = 'auto', + panel_version: Literal['auto', 'local'] | str = 'auto', + http_patch: bool = True, +) -> list[str]: + """ + Make sense of python requirements for our Panel script. + + Arguments + --------- + filename: str | Path | IO + The filename of the Panel/Bokeh application to convert. + requirements: List[str] + The list of requirements to include (in addition to Panel). + panel_version: 'auto' | str + The panel release version to use in the exported HTML. + http_patch: bool + Whether to patch the HTTP request stack with the pyodide-http library + to allow urllib3 and requests to work. + """ + + # Environment + if panel_version == 'local': + panel_req = './' + str(PANEL_LOCAL_WHL.as_posix()).split('/')[-1] + bokeh_req = './' + str(BOKEH_LOCAL_WHL.as_posix()).split('/')[-1] + elif panel_version == 'auto': + panel_req = PANEL_CDN_WHL + bokeh_req = BOKEH_CDN_WHL + else: + panel_req = f'panel=={panel_version}' + bokeh_req = f'bokeh=={BOKEH_VERSION}' + collected_requirements = [bokeh_req, panel_req] + if http_patch: + collected_requirements.append('pyodide-http') + + requirements_root = os.getcwd() + if requirements == 'auto': + with open(main_app, 'r') as mainfile: + source = mainfile.read() + requirements = find_requirements(source) + elif isinstance(requirements, str) and pathlib.Path(requirements).is_file(): + requirements_root = os.path.dirname(requirements) + requirements = ( + pathlib.Path(requirements).read_text(encoding='utf-8').splitlines() + ) + + from packaging.requirements import Requirement + + for raw_req in requirements: + stripped_req = raw_req.split('#')[0].strip() + if not len(stripped_req) > 0: + continue + try: + req = Requirement(stripped_req) + except ValueError as e: + if stripped_req.endswith('.whl'): + req = DummyRequirement(stripped_req) + else: + raise ValueError(f'Requirements parser raised following error: {e}') from e + + if req.name in ('panel', 'bokeh'): + continue + elif req.url is not None: + parsed_req = urlparse(req.url) + if parsed_req.scheme in ('https', 'http'): + collected_requirements.append(req.url) + elif parsed_req.scheme in ('file', ''): + check_path = parsed_req.path + check_path = os.path.normpath( + os.path.join(requirements_root, check_path) + ) + if os.path.exists(check_path): + collected_requirements.append( + f'file:{check_path}' + ) # make a custom URL so things can be handled as a URL + else: + raise ValueError(f'Could not verify path for {req}. Make sure the file is available if it is a local wheel.') + else: + collected_requirements.append(f'{req.name}{req.specifier}') + + return collected_requirements + + +def pack_files(filemap: dict, destination: str | os.PathLike | IO): + """ + Pack files into a zipfile for distribution + + Arguments + --------- + filemap: dict + A dictionary mapping a local file to an archive name + destination: str | os.PathLike | IO + where to put the output zip + """ + with ZipFile(destination, 'w') as packfile: + for fname, arcname in filemap.items(): + packfile.write(fname, arcname=arcname) + + def script_to_html( filename: str | os.PathLike | IO, - requirements: Literal['auto'] | list[str] = 'auto', + requirements: list[str], + app_resources: os.PathLike = None, js_resources: Literal['auto'] | list[str] = 'auto', css_resources: Literal['auto'] | list[str] | None = 'auto', runtime: Runtimes = 'pyodide', prerender: bool = True, - panel_version: Literal['auto', 'local'] | str = 'auto', manifest: str | None = None, - http_patch: bool = True, inline: bool = False, compiled: bool = True ) -> str: @@ -191,8 +304,10 @@ def script_to_html( --------- filename: str | Path | IO The filename of the Panel/Bokeh application to convert. - requirements: 'auto' | List[str] - The list of requirements to include (in addition to Panel). + requirements: List[str] + The preprocessed, micropip-compatible list of requirements to include. + app_resources: os.PathLike + relative path of zip with data to extract js_resources: 'auto' | List[str] The list of JS resources to include in the exported HTML. css_resources: 'auto' | List[str] | None @@ -201,16 +316,12 @@ def script_to_html( The runtime to use for running Python in the browser. prerender: bool Whether to pre-render the components so the page loads. - panel_version: 'auto' | str - The panel release version to use in the exported HTML. - http_patch: bool - Whether to patch the HTTP request stack with the pyodide-http library - to allow urllib3 and requests to work. inline: bool Whether to inline resources. compiled: bool Whether to use pre-compiled pyodide bundles. """ + # Run script if hasattr(filename, 'read'): handler = CodeHandler(source=filename.read(), filename='convert.py') @@ -234,41 +345,6 @@ def script_to_html( 'the bokeh document manually.' ) - if requirements == 'auto': - requirements = find_requirements(source) - elif isinstance(requirements, str) and pathlib.Path(requirements).is_file(): - requirements = pathlib.Path(requirements).read_text(encoding='utf-8').splitlines() - try: - from packaging.requirements import Requirement - requirements = [ - r2 for r in requirements - if (r2 := r.split("#")[0].strip()) and Requirement(r2) - ] - except Exception as e: - raise ValueError( - f'Requirements parser raised following error: {e}' - ) from e - - # Environment - if panel_version == 'local': - panel_req = './' + str(PANEL_LOCAL_WHL.as_posix()).split('/')[-1] - bokeh_req = './' + str(BOKEH_LOCAL_WHL.as_posix()).split('/')[-1] - elif panel_version == 'auto': - panel_req = PANEL_CDN_WHL - bokeh_req = BOKEH_CDN_WHL - else: - panel_req = f'panel=={panel_version}' - bokeh_req = f'bokeh=={BOKEH_VERSION}' - base_reqs = [bokeh_req, panel_req] - if http_patch: - base_reqs.append('pyodide-http==0.2.1') - reqs = base_reqs + [ - req for req in requirements if req not in ('panel', 'bokeh') - ] - for name, min_version in MINIMUM_VERSIONS.items(): - if any(name in req for req in reqs): - reqs = [f'{name}>={min_version}' if name in req else req for req in reqs] - # Execution post_code = POST_PYSCRIPT if runtime == 'pyscript' else POST code = '\n'.join([PRE, source, post_code]) @@ -282,7 +358,11 @@ def script_to_html( css_resources = [PYSCRIPT_CSS, PYSCRIPT_CSS_OVERRIDES] elif not css_resources: css_resources = [] - pyconfig = json.dumps({'packages': reqs, 'plugins': ["!error"]}) + pyconfig = json.dumps({ + 'packages': requirements, + 'plugins': ['!error'], + 'files': {app_resources: './*'} if app_resources else {}, + }) if 'worker' in runtime: plot_script = f'' web_worker = code @@ -291,8 +371,9 @@ def script_to_html( else: if css_resources == 'auto': css_resources = [] - env_spec = ', '.join([repr(req) for req in reqs]) - code = code.encode("unicode_escape").decode("utf-8").replace('`', '\`') + data_archives = f'{repr(app_resources)}' if app_resources else '' + env_spec = ', '.join([repr(req) for req in requirements]) + code = code.encode('unicode_escape').decode('utf-8').replace('`', r'\`') if runtime == 'pyodide-worker': if js_resources == 'auto': js_resources = [] @@ -302,6 +383,7 @@ def script_to_html( }) web_worker = WEB_WORKER_TEMPLATE.render({ 'PYODIDE_URL': PYODIDE_PYC_URL if compiled else PYODIDE_URL, + 'data_archives': data_archives, 'env_spec': env_spec, 'code': code }) @@ -311,6 +393,7 @@ def script_to_html( js_resources = [PYODIDE_PYC_JS if compiled else PYODIDE_JS] script_template = _pn_env.from_string(PYODIDE_SCRIPT) plot_script = script_template.render({ + 'data_archives': data_archives, 'env_spec': env_spec, 'code': code }) @@ -324,9 +407,9 @@ def script_to_html( plot_script += wrap_in_script_tag(script_for_render_items(json_id, render_items)) else: render_item = RenderItem( - token = '', - roots = document.roots, - use_for_title = False + token='', + roots=document.roots, + use_for_title=False ) render_items = [render_item] @@ -342,7 +425,7 @@ def script_to_html( if template in (BASE_TEMPLATE, FILE): # Add loading.css if not served from Panel template if inline: - loading_base = (DIST_DIR / "css" / "loading.css").read_text(encoding='utf-8') + loading_base = (DIST_DIR / 'css' / 'loading.css').read_text(encoding='utf-8') loading_style = f'' else: loading_style = f'' @@ -395,6 +478,7 @@ def convert_app( app: str | os.PathLike, dest_path: str | os.PathLike | None = None, requirements: list[str] | Literal['auto'] | os.PathLike = 'auto', + resources: list[str] | list[os.PathLike] = None, runtime: Runtimes = 'pyodide-worker', prerender: bool = True, manifest: str | None = None, @@ -409,12 +493,57 @@ def convert_app( elif not isinstance(dest_path, pathlib.PurePath): dest_path = pathlib.Path(dest_path) + app_folder = os.path.dirname(app) + app_name = '.'.join(os.path.basename(app).split('.')[:-1]) + + parsed_requirements = collect_python_requirements( + app, requirements, panel_version=panel_version, http_patch=http_patch + ) + # prepare wheels to be available via emscripten MEMFS + parsed_requirements_rewritten = [] + wheels2pack = {} + + for req in parsed_requirements: + try: + req_as_url = urlparse(req) + if req_as_url.scheme == 'file': + wheel_name = os.path.basename(req_as_url.path) + emfs_wheel_path = os.path.join('packed_wheels', wheel_name) + parsed_requirements_rewritten.append(f'emfs:{emfs_wheel_path}') + wheels2pack[req_as_url.path] = emfs_wheel_path + else: + parsed_requirements_rewritten.append(req) + except ValueError: + # no url, so must be a properly formatted requirement + parsed_requirements_rewritten.append(req) + + # make a zip out of resources + resources_validated = {} + for resourcepath in resources: + commonpath = pathlib.Path( + os.path.commonpath( + [os.path.abspath(resourcepath), os.path.abspath(app_folder)] + ) + ) + if commonpath.resolve() == pathlib.Path(app_folder).resolve(): + resources_validated[resourcepath] = os.path.relpath( + resourcepath, app_folder + ) + else: + raise ValueError('resources have to be in a folder rootable at the app-directory') + + # resources unpacked into emscripten MEMFS + app_resources = {**wheels2pack, **resources_validated} + app_resources_packfile = f'{app_name}.resources.zip' + pack_files(app_resources, os.path.join(dest_path, app_resources_packfile)) + + # try to convert the app to a standalone package try: with set_resource_mode('inline' if inline else 'cdn'): html, worker = script_to_html( - app, requirements=requirements, runtime=runtime, + app, requirements=parsed_requirements_rewritten, + app_resources=app_resources_packfile, runtime=runtime, prerender=prerender, manifest=manifest, - panel_version=panel_version, http_patch=http_patch, inline=inline, compiled=compiled ) except KeyboardInterrupt: @@ -422,20 +551,21 @@ def convert_app( except Exception as e: print(f'Failed to convert {app} to {runtime} target: {e}') return - name = '.'.join(os.path.basename(app).split('.')[:-1]) - filename = f'{name}.html' - with open(dest_path / filename, 'w', encoding="utf-8") as out: + # write out the app + filename = f'{app_name}.html' + + with open(dest_path / filename, 'w', encoding='utf-8') as out: out.write(html) if runtime == 'pyscript-worker': - with open(dest_path / f'{name}.py', 'w', encoding="utf-8") as out: + with open(dest_path / f'{app_name}.py', 'w', encoding='utf-8') as out: out.write(worker) elif runtime == 'pyodide-worker': - with open(dest_path / f'{name}.js', 'w', encoding="utf-8") as out: + with open(dest_path / f'{app_name}.js', 'w', encoding='utf-8') as out: out.write(worker) if verbose: print(f'Successfully converted {app} to {runtime} target and wrote output to {filename}.') - return (name.replace('_', ' '), filename) + return (app_name.replace('_', ' '), filename) def _convert_process_pool( @@ -472,12 +602,14 @@ def _convert_process_pool( files[name] = filename return files + def convert_apps( apps: str | os.PathLike | list[str | os.PathLike], dest_path: str | os.PathLike | None = None, title: str | None = None, runtime: Runtimes = 'pyodide-worker', requirements: list[str] | Literal['auto'] | os.PathLike = 'auto', + resources: list[str] | list[os.PathLike] = None, prerender: bool = True, build_index: bool = True, build_pwa: bool = True, @@ -522,7 +654,7 @@ def convert_apps( max_workers: int The maximum number of parallel workers panel_version: 'auto' | 'local'] | str -' The panel version to include. + The panel version to include. http_patch: bool Whether to patch the HTTP request stack with the pyodide-http library to allow urllib3 and requests to work. @@ -553,10 +685,16 @@ def convert_apps( app_requirements = requirements kwargs = { - 'requirements': app_requirements, 'runtime': runtime, - 'prerender': prerender, 'manifest': manifest, - 'panel_version': panel_version, 'http_patch': http_patch, - 'inline': inline, 'verbose': verbose, 'compiled': compiled + 'requirements': app_requirements, + 'resources': resources if resources else [], + 'runtime': runtime, + 'prerender': prerender, + 'manifest': manifest, + 'panel_version': panel_version, + 'http_patch': http_patch, + 'inline': inline, + 'verbose': verbose, + 'compiled': compiled } if state._is_pyodide: @@ -589,7 +727,7 @@ def convert_apps( # Write manifest manifest = build_pwa_manifest(files, title=title, **pwa_config) - with open(dest_path / 'site.webmanifest', 'w', encoding="utf-8") as f: + with open(dest_path / 'site.webmanifest', 'w', encoding='utf-8') as f: f.write(manifest) if verbose: print('Successfully wrote site.manifest.') @@ -600,7 +738,7 @@ def convert_apps( name=title or 'Panel Pyodide App', pre_cache=', '.join([repr(p) for p in img_rel]) ) - with open(dest_path / 'serviceWorker.js', 'w', encoding="utf-8") as f: + with open(dest_path / 'serviceWorker.js', 'w', encoding='utf-8') as f: f.write(worker) if verbose: print('Successfully wrote serviceWorker.js.') From 058d549a34d5bbeed51162ead54c2e9b09a290b8 Mon Sep 17 00:00:00 2001 From: Felix Mayr <_@flxmr.de> Date: Wed, 22 May 2024 00:32:21 +0200 Subject: [PATCH 2/2] update docs for WASM (new features + logical order) --- doc/how_to/wasm/convert.md | 11 ++++++++--- doc/how_to/wasm/index.md | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/doc/how_to/wasm/convert.md b/doc/how_to/wasm/convert.md index f34beb3cc5..2c3d5b8919 100644 --- a/doc/how_to/wasm/convert.md +++ b/doc/how_to/wasm/convert.md @@ -9,7 +9,7 @@ The ``panel convert`` command has the following options: options: -h, --help show this help message and exit - --to TO The format to convert to, one of 'pyodide' (default), 'pyodide-worker' or 'pyscript' + --to TO The format to convert to, one of 'pyodide' (default), 'pyodide-worker', 'pyscript' or 'pyscript-worker' --compiled Whether to use the compiled and faster version of Pyodide. --out OUT The directory to write the file to. --title TITLE A custom title for the application(s). @@ -17,11 +17,13 @@ The ``panel convert`` command has the following options: --index Whether to create an index if multiple files are served. --pwa Whether to add files to serve applications as a Progressive Web App. --requirements REQUIREMENTS [REQUIREMENTS ...] - Explicit requirements to add to the converted file, a single requirements.txt file or a JSON file containing requirements per app. By default requirements are inferred from the code. + Explicit requirements to add to the converted file, a single requirements.txt file or a JSON file containing requirements per app. By default requirements are inferred from the code. + --resources RESOURCES [RESOURCES ...] + Files to pack for distribution with the app. Does only support files located in the directory of the main panel app (or in subdirectories below). --disable-http-patch Whether to disable patching http requests using the pyodide-http library. --watch Watch the files --num-procs NUM_PROCS - The number of processes to start in parallel to convert the apps. + The number of processes to start in parallel to convert the apps. ## Example @@ -89,6 +91,7 @@ Using the `--to` argument on the CLI you can control the format of the file that - **`pyodide`** (default): Run application using Pyodide running in the main thread. This option is less performant than pyodide-worker but produces completely standalone HTML files that do not have to be hosted on a static file server (e.g. Github Pages). - **`pyodide-worker`**: Generates an HTML file and a JS file containing a Web Worker that runs in a separate thread. This is the most performant option, but files have to be hosted on a static file server. - **`pyscript`**: Generates an HTML leveraging PyScript. This produces standalone HTML files containing `` and `` tags containing the dependencies and the application code. This output is the most readable, and should have equivalent performance to the `pyodide` option. +- **`pyscript-worker`**: Generates an HTML file and a separate PY file containing the panel app leveraging PyScript. This needs proper setup of Content-Security-Policies on the webserver. ## Requirements @@ -104,6 +107,8 @@ Alternatively you may also provide a `requirements.txt` file: panel convert script.py --to pyodide-worker --out pyodide --requirements requirements.txt ``` +One also can provide URLs to Python wheels provided at filesystem or online locations. Wheels available in the local filesystem will be packed into a zip-file (which needs to be hosted with the app) which will be unpacked to emscriptens MEMFS for installation. + ## Index If you convert multiple applications at once you may want to add an index to be able to navigate between the applications easily. To enable the index simply pass `--index` to the convert command. diff --git a/doc/how_to/wasm/index.md b/doc/how_to/wasm/index.md index cc2bd76777..8e90d995b2 100644 --- a/doc/how_to/wasm/index.md +++ b/doc/how_to/wasm/index.md @@ -53,8 +53,8 @@ Note that since Panel is built on Bokeh server and Tornado it is also possible t :hidden: :maxdepth: 2 -convert standalone +convert sphinx jupyterlite ```