from __future__ import annotations
import concurrent.futures
import dataclasses
import os
import pathlib
import uuid
from typing import (
IO, Any, Dict, List,
)
import bokeh
from bokeh.application.application import Application, SessionContext
from bokeh.application.handlers.code import CodeHandler
from bokeh.core.json_encoder import serialize_json
from bokeh.core.templates import MACROS, get_env
from bokeh.document import Document
from bokeh.embed.elements import script_for_render_items
from bokeh.embed.util import RenderItem, standalone_docs_json_and_render_items
from bokeh.embed.wrappers import wrap_in_script_tag
from bokeh.util.serialization import make_id
from typing_extensions import Literal
from .. import __version__, config
from ..util import base_version, escape
from .loading import LOADING_INDICATOR_CSS_CLASS
from .markdown import build_single_handler_application
from .mime_render import find_imports
from .resources import (
BASE_TEMPLATE, CDN_DIST, DIST_DIR, INDEX_TEMPLATE, Resources,
_env as _pn_env, bundle_resources, loading_css, set_resource_mode,
)
from .state import set_curdoc, state
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')
PANEL_ROOT = pathlib.Path(__file__).parent.parent
BOKEH_VERSION = base_version(bokeh.__version__)
PY_VERSION = base_version(__version__)
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'
BOKEH_CDN_WHL = f'{CDN_DIST}wheels/bokeh-{BOKEH_VERSION}-py3-none-any.whl'
PYODIDE_URL = 'https://cdn.jsdelivr.net/pyodide/v0.23.0/full/pyodide.js'
PYSCRIPT_CSS = '<link rel="stylesheet" href="https://pyscript.net/releases/2022.12.1/pyscript.css" />'
PYSCRIPT_JS = '<script defer src="https://pyscript.net/releases/2022.12.1/pyscript.js"></script>'
PYODIDE_JS = f'<script src="{PYODIDE_URL}"></script>'
MINIMUM_VERSIONS = {}
ICON_DIR = DIST_DIR / 'images'
PWA_IMAGES = [
ICON_DIR / 'favicon.ico',
ICON_DIR / 'icon-vector.svg',
ICON_DIR / 'icon-32x32.png',
ICON_DIR / 'icon-192x192.png',
ICON_DIR / 'icon-512x512.png',
ICON_DIR / 'apple-touch-icon.png',
ICON_DIR / 'index_background.png'
]
Runtimes = Literal['pyodide', 'pyscript', 'pyodide-worker']
PRE = """
import asyncio
from panel.io.pyodide import init_doc, write_doc
init_doc()
"""
POST = """
await write_doc()"""
POST_PYSCRIPT = """
asyncio.ensure_future(write_doc());"""
PYODIDE_SCRIPT = """
<script type="text/javascript">
async function main() {
let pyodide = await loadPyodide();
await pyodide.loadPackage("micropip");
await pyodide.runPythonAsync(`
import micropip
await micropip.install([{{ env_spec }}]);
`);
code = `{{ code }}`
await pyodide.runPythonAsync(code);
}
main();
</script>
"""
INIT_SERVICE_WORKER = """
<script type="text/javascript">
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./serviceWorker.js').then(reg => {
reg.onupdatefound = () => {
const installingWorker = reg.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed' &&
navigator.serviceWorker.controller) {
// Reload page if service worker is replaced
location.reload();
}
}
}
})
}
</script>
"""
[docs]@dataclasses.dataclass
class Request:
headers : dict
cookies : dict
arguments : dict
[docs]class MockSessionContext(SessionContext):
def __init__(self, *args, document=None, **kwargs):
self._document = document
super().__init__(*args, server_context=None, session_id=None, **kwargs)
[docs] def with_locked_document(self, *args):
return
@property
def destroyed(self) -> bool:
return False
@property
def request(self):
return Request(headers={}, cookies={}, arguments={})
def make_index(files, title=None, manifest=True):
if manifest:
manifest = 'site.webmanifest'
favicon = 'images/favicon.ico'
apple_icon = 'images/apple-touch-icon.png'
else:
manifest = favicon = apple_icon = None
items = {label: './'+os.path.basename(f) for label, f in sorted(files.items())}
return INDEX_TEMPLATE.render(
items=items, manifest=manifest, apple_icon=apple_icon,
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'
path = 'index.html'
else:
title = title or 'Panel Applications'
path = list(files.values())[0]
return PWA_MANIFEST_TEMPLATE.render(
name=title,
path=path,
**kwargs
)
[docs]def script_to_html(
filename: str | os.PathLike | IO,
requirements: Literal['auto'] | List[str] = 'auto',
js_resources: Literal['auto'] | List[str] = 'auto',
css_resources: Literal['auto'] | List[str] | None = None,
runtime: Runtimes = 'pyodide',
prerender: bool = True,
panel_version: Literal['auto', 'local'] | str = 'auto',
manifest: str | None = None,
http_patch: bool = True,
inline: bool = False
) -> str:
"""
Converts a Panel or Bokeh script to a standalone WASM Python
application.
Arguments
---------
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).
js_resources: 'auto' | List[str]
The list of JS resources to include in the exported HTML.
css_resources: 'auto' | List[str] | None
The list of CSS resources to include in the exported HTML.
runtime: 'pyodide' | 'pyscript'
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.
"""
# Run script
if hasattr(filename, 'read'):
handler = CodeHandler(source=filename.read(), filename='convert.py')
app_name = f'app-{str(uuid.uuid4())}'
app = Application(handler)
else:
path = pathlib.Path(filename)
app_name = '.'.join(path.name.split('.')[:-1])
app = build_single_handler_application(str(path.absolute()))
document = Document()
document._session_context = lambda: MockSessionContext(document=document)
with set_curdoc(document):
app.initialize_document(document)
state._on_load(None)
source = app._handlers[0]._runner.source
if not document.roots:
raise RuntimeError(
f'The file {filename} does not publish any Panel contents. '
'Ensure you have marked items as servable or added models to '
'the bokeh document manually.'
)
if requirements == 'auto':
requirements = find_imports(source)
elif isinstance(requirements, str) and pathlib.Path(requirements).is_file():
requirements = pathlib.Path(requirements).read_text(encoding='utf-8').split('/n')
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}'
)
# 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 = ['markdown-it-py<3', 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])
web_worker = None
if css_resources is None:
css_resources = []
if runtime == 'pyscript':
if js_resources == 'auto':
js_resources = [PYSCRIPT_JS]
css_resources = []
if css_resources == 'auto':
css_resources = [PYSCRIPT_CSS]
pyenv = ','.join([repr(req) for req in reqs])
plot_script = f'<py-config>\npackages = [{pyenv}]\n</py-config>\n<py-script>{code}</py-script>'
else:
if css_resources == 'auto':
css_resources = []
env_spec = ', '.join([repr(req) for req in reqs])
code = code.replace('`', '\`').replace('\\n', r'\\n')
if runtime == 'pyodide-worker':
if js_resources == 'auto':
js_resources = []
worker_handler = WORKER_HANDLER_TEMPLATE.render({
'name': app_name,
'loading_spinner': config.loading_spinner
})
web_worker = WEB_WORKER_TEMPLATE.render({
'PYODIDE_URL': PYODIDE_URL,
'env_spec': env_spec,
'code': code
})
plot_script = wrap_in_script_tag(worker_handler)
else:
if js_resources == 'auto':
js_resources = [PYODIDE_JS]
script_template = _pn_env.from_string(PYODIDE_SCRIPT)
plot_script = script_template.render({
'env_spec': env_spec,
'code': code
})
if prerender:
json_id = make_id()
docs_json, render_items = standalone_docs_json_and_render_items(document)
render_item = render_items[0]
json = escape(serialize_json(docs_json), quote=False)
plot_script += wrap_in_script_tag(json, "application/json", json_id)
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
)
render_items = [render_item]
# Collect resources
resources = Resources(mode='inline' if inline else 'cdn')
loading_base = (DIST_DIR / "css" / "loading.css").read_text(encoding='utf-8')
spinner_css = loading_css(
config.loading_spinner, config.loading_color, config.loading_max_height
)
css_resources.append(
f'<style type="text/css">\n{loading_base}\n{spinner_css}\n</style>'
)
with set_curdoc(document):
bokeh_js, bokeh_css = bundle_resources(document.roots, resources)
extra_js = [INIT_SERVICE_WORKER, bokeh_js] if manifest else [bokeh_js]
bokeh_js = '\n'.join(js_resources+extra_js)
bokeh_css = '\n'.join([bokeh_css]+css_resources)
# Configure template
template = document.template
template_variables = document._template_variables
context = template_variables.copy()
context.update(dict(
title=document.title,
bokeh_js=bokeh_js,
bokeh_css=bokeh_css,
plot_script=plot_script,
docs=render_items,
base=BASE_TEMPLATE,
macros=MACROS,
doc=render_item,
roots=render_item.roots,
manifest=manifest,
dist_url=CDN_DIST
))
# Render
if template is None:
template = BASE_TEMPLATE
elif isinstance(template, str):
template = get_env().from_string("{% extends base %}\n" + template)
html = template.render(context)
html = (html
.replace('<body>', f'<body class="{LOADING_INDICATOR_CSS_CLASS} pn-{config.loading_spinner}">')
)
return html, web_worker
def convert_app(
app: str | os.PathLike,
dest_path: str | os.PathLike | None = None,
requirements: List[str] | Literal['auto'] | os.PathLike = 'auto',
runtime: Runtimes = 'pyodide-worker',
prerender: bool = True,
manifest: str | None = None,
panel_version: Literal['auto', 'local'] | str = 'auto',
http_patch: bool = True,
inline: bool = False,
verbose: bool = True,
):
if dest_path is None:
dest_path = pathlib.Path('./')
elif not isinstance(dest_path, pathlib.PurePath):
dest_path = pathlib.Path(dest_path)
try:
with set_resource_mode('inline' if inline else 'cdn'):
html, js_worker = script_to_html(
app, requirements=requirements, runtime=runtime,
prerender=prerender, manifest=manifest,
panel_version=panel_version, http_patch=http_patch,
inline=inline
)
except KeyboardInterrupt:
return
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:
out.write(html)
if runtime == 'pyodide-worker':
with open(dest_path / f'{name}.js', 'w', encoding="utf-8") as out:
out.write(js_worker)
if verbose:
print(f'Successfully converted {app} to {runtime} target and wrote output to {filename}.')
return (name.replace('_', ' '), filename)
def _convert_process_pool(
apps: List[str],
dest_path: str | None = None,
max_workers: int = 4,
requirements: List[str] | Literal['auto'] | os.PathLike = 'auto',
**kwargs
):
import multiprocessing as mp
from concurrent.futures import ProcessPoolExecutor
files = {}
groups = [apps[i:i+max_workers] for i in range(0, len(apps), max_workers)]
for group in groups:
with ProcessPoolExecutor(
max_workers=max_workers, mp_context=mp.get_context('spawn')
) as executor:
futures = []
for app in group:
if isinstance(requirements, dict):
app_requires = requirements.get(app, 'auto')
else:
app_requires = requirements
f = executor.submit(
convert_app, app, dest_path, requirements=app_requires, **kwargs
)
futures.append(f)
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result is not None:
name, filename = result
files[name] = filename
return files
[docs]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',
prerender: bool = True,
build_index: bool = True,
build_pwa: bool = True,
pwa_config: Dict[Any, Any] = {},
max_workers: int = 4,
panel_version: Literal['auto', 'local'] | str = 'auto',
http_patch: bool = True,
inline: bool = False,
verbose: bool = True,
):
"""
Arguments
---------
apps: str | List[str]
The filename(s) of the Panel/Bokeh application(s) to convert.
dest_path: str | pathlib.Path
The directory to write the converted application(s) to.
title: str | None
A title for the application(s). Also used to generate unique
name for the application cache to ensure.
runtime: 'pyodide' | 'pyscript' | 'pyodide-worker'
The runtime to use for running Python in the browser.
requirements: 'auto' | List[str] | os.PathLike | Dict[str, 'auto' | List[str] | os.PathLike]
The list of requirements to include (in addition to Panel).
By default automatically infers dependencies from imports
in the application. May also provide path to a requirements.txt
prerender: bool
Whether to pre-render the components so the page loads.
build_index: bool
Whether to write an index page (if there are multiple apps).
build_pwa: bool
Whether to write files to define a progressive web app (PWA) including
a manifest and a service worker that caches the application locally
pwa_config: Dict[Any, Any]
Configuration for the PWA including (see https://developer.mozilla.org/en-US/docs/Web/Manifest)
- display: Display options ('fullscreen', 'standalone', 'minimal-ui' 'browser')
- orientation: Preferred orientation
- background_color: The background color of the splash screen
- theme_color: The theme color of the application
max_workers: int
The maximum number of parallel workers
panel_version: 'auto' | 'local'] | str
' 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.
inline: bool
Whether to inline resources.
"""
if isinstance(apps, str):
apps = [apps]
if dest_path is None:
dest_path = pathlib.Path('./')
elif not isinstance(dest_path, pathlib.PurePath):
dest_path = pathlib.Path(dest_path)
dest_path.mkdir(parents=True, exist_ok=True)
manifest = 'site.webmanifest' if build_pwa else None
if isinstance(requirements, dict):
app_requirements = {}
for app in apps:
matches = [
deps for name, deps in requirements.items()
if app.endswith(name.replace(os.path.sep, '/'))
]
app_requirements[app] = matches[0] if matches else 'auto'
else:
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
}
if state._is_pyodide:
files = dict((convert_app(app, dest_path, **kwargs) for app in apps))
else:
files = _convert_process_pool(
apps, dest_path, max_workers=max_workers, **kwargs
)
if build_index and len(files) >= 1:
index = make_index(files, manifest=build_pwa, title=title)
with open(dest_path / 'index.html', 'w') as f:
f.write(index)
if verbose:
print('Successfully wrote index.html.')
if not build_pwa:
return
# Write icons
imgs_path = (dest_path / 'images')
imgs_path.mkdir(exist_ok=True)
img_rel = []
for img in PWA_IMAGES:
with open(imgs_path / img.name, 'wb') as f:
f.write(img.read_bytes())
img_rel.append(f'images/{img.name}')
if verbose:
print('Successfully wrote icons and images.')
# Write manifest
manifest = build_pwa_manifest(files, title=title, **pwa_config)
with open(dest_path / 'site.webmanifest', 'w', encoding="utf-8") as f:
f.write(manifest)
if verbose:
print('Successfully wrote site.manifest.')
# Write service worker
worker = SERVICE_WORKER_TEMPLATE.render(
uuid=uuid.uuid4().hex,
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:
f.write(worker)
if verbose:
print('Successfully wrote serviceWorker.js.')