diff --git a/panel/io/mime_render.py b/panel/io/mime_render.py index c1c657b1c0..00180fc077 100644 --- a/panel/io/mime_render.py +++ b/panel/io/mime_render.py @@ -1,25 +1,30 @@ +""" +Utilities for executing Python code and rendering the resulting output +using a similar MIME-type based rendering system as implemented by +IPython. + +Attempts to limit the actual MIME types that have to be rendered on +to a minimum simplifying frontend implementation: + + - application/bokeh: Model JSON representation + - text/plain: HTML escaped string output + - text/html: HTML code to insert into the DOM +""" + from __future__ import annotations import ast import base64 import copy import io -import json +import traceback +from contextlib import redirect_stderr, redirect_stdout from html import escape -from typing import Any, Dict, Tuple +from typing import Any, Dict import markdown -from bokeh import __version__ -from bokeh.document import Document -from bokeh.embed.util import standalone_docs_json_and_render_items -from bokeh.model import Model - -from ..pane import panel -from ..viewable import Viewable, Viewer -from .state import state - UNDEFINED = object() #--------------------------------------------------------------------- @@ -49,7 +54,8 @@ def exec_with_return(code: str, global_context: Dict[str, Any] = None) -> Any: Returns ------- - The return value of the executed code. + + The return value of the executed code and stdout and stederr output. """ global_context = global_context if global_context else globals() code_ast = ast.parse(code) @@ -60,18 +66,25 @@ def exec_with_return(code: str, global_context: Dict[str, Any] = None) -> Any: last_ast = copy.deepcopy(code_ast) last_ast.body = code_ast.body[-1:] - exec(compile(init_ast, "", "exec"), global_context) - if type(last_ast.body[0]) == ast.Expr: - return eval(compile(_convert_expr(last_ast.body[0]), "", "eval"), global_context) - else: - exec(compile(last_ast, "", "exec"), global_context) - return UNDEFINED + stdout = io.StringIO() + stderr = io.StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + try: + exec(compile(init_ast, "", "exec"), global_context) + if type(last_ast.body[0]) == ast.Expr: + out = eval(compile(_convert_expr(last_ast.body[0]), "", "eval"), global_context) + else: + exec(compile(last_ast, "", "exec"), global_context) + out = UNDEFINED + except Exception: + out = UNDEFINED + traceback.print_exc(file=stderr) + return out, stdout.getvalue(), stderr.getvalue() #--------------------------------------------------------------------- # MIME Render API #--------------------------------------------------------------------- - MIME_METHODS = { "__repr__": "text/plain", "_repr_html_": "text/html", @@ -83,10 +96,14 @@ def exec_with_return(code: str, global_context: Dict[str, Any] = None) -> Any: "_repr_latex": "text/latex", "_repr_json_": "application/json", "_repr_javascript_": "application/javascript", - "savefig": "image/png", - "servable": "" + "savefig": "image/png" } +# Rendering function + +def render_svg(value, meta, mime): + return value, 'text/html' + def render_image(value, meta, mime): data = f"data:{mime};charset=utf-8;base64,{value}" attrs = " ".join(['{k}="{v}"' for k, v in meta.items()]) @@ -100,56 +117,27 @@ def render_markdown(value, meta, mime): value, extensions=["extra", "smarty", "codehilite"], output_format='html5' ), 'text/html') +def render_pdf(value, meta, mime): + data = value.encode('utf-8') + base64_pdf = base64.b64encode(data).decode("utf-8") + src = f"data:application/pdf;base64,{base64_pdf}" + return f'', 'text/html' + def identity(value, meta, mime): return value, mime MIME_RENDERERS = { - "text/plain": identity, - "text/html": identity, "image/png": render_image, "image/jpeg": render_image, "image/svg+xml": identity, "application/json": identity, "application/javascript": render_javascript, - "text/markdown": render_markdown + "application/pdf": render_pdf, + "text/html": identity, + "text/markdown": render_markdown, + "text/plain": identity, } -def _model_json(model: Model, target: str) -> Tuple[Document, str]: - """ - Renders a Bokeh Model to JSON representation given a particular - DOM target and returns the Document and the serialized JSON string. - - Arguments - --------- - model: bokeh.model.Model - The bokeh model to render. - target: str - The id of the DOM node to render to. - - Returns - ------- - document: Document - The bokeh Document containing the rendered Bokeh Model. - model_json: str - The serialized JSON representation of the Bokeh Model. - """ - doc = Document() - model.server_doc(doc=doc) - model = doc.roots[0] - docs_json, _ = standalone_docs_json_and_render_items( - [model], suppress_callback_warning=True - ) - - doc_json = list(docs_json.values())[0] - root_id = doc_json['roots']['root_ids'][0] - - return doc, json.dumps(dict( - target_id = target, - root_id = root_id, - doc = doc_json, - version = __version__, - )) - def eval_formatter(obj, print_method): """ Evaluates a formatter method. @@ -173,11 +161,6 @@ def format_mime(obj): """ if isinstance(obj, str): return escape(obj), "text/plain" - elif isinstance(obj, (Model, Viewable, Viewer)): - doc, out = _model_json(panel(obj), 'output-${msg.id}') - state.cache['${msg.id}'] = doc - return out, 'application/bokeh' - mimebundle = eval_formatter(obj, "_repr_mimebundle_") if isinstance(mimebundle, tuple): format_dict, _ = mimebundle diff --git a/panel/io/pyodide.py b/panel/io/pyodide.py index e6542def5c..b743d1c72f 100644 --- a/panel/io/pyodide.py +++ b/panel/io/pyodide.py @@ -11,11 +11,13 @@ import pyodide # isort: split +from bokeh import __version__ from bokeh.document import Document from bokeh.embed.elements import script_for_render_items from bokeh.embed.util import standalone_docs_json_and_render_items from bokeh.embed.wrappers import wrap_in_script_tag from bokeh.io.doc import set_curdoc +from bokeh.model import Model from bokeh.protocol.messages.patch_doc import process_document_events from js import JSON @@ -23,13 +25,12 @@ from ..util import isurl from . import resources from .document import MockSessionContext -from .mime_render import _model_json +from .mime_render import UNDEFINED, exec_with_return, format_mime from .state import state resources.RESOURCE_MODE = 'CDN' os.environ['BOKEH_RESOURCES'] = 'cdn' - #--------------------------------------------------------------------- # Private API #--------------------------------------------------------------------- @@ -92,6 +93,42 @@ def _doc_json(doc: Document, root_els=None) -> Tuple[str, str, str]: }) return json.dumps(docs_json), json.dumps(render_items_json), json.dumps(root_ids) +def _model_json(model: Model, target: str) -> Tuple[Document, str]: + """ + Renders a Bokeh Model to JSON representation given a particular + DOM target and returns the Document and the serialized JSON string. + + Arguments + --------- + model: bokeh.model.Model + The bokeh model to render. + target: str + The id of the DOM node to render to. + + Returns + ------- + document: Document + The bokeh Document containing the rendered Bokeh Model. + model_json: str + The serialized JSON representation of the Bokeh Model. + """ + doc = Document() + model.server_doc(doc=doc) + model = doc.roots[0] + docs_json, _ = standalone_docs_json_and_render_items( + [model], suppress_callback_warning=True + ) + + doc_json = list(docs_json.values())[0] + root_id = doc_json['roots']['root_ids'][0] + + return doc, json.dumps(dict( + target_id = target, + root_id = root_id, + doc = doc_json, + version = __version__, + )) + def _link_docs(pydoc: Document, jsdoc: Any) -> None: """ Links Python and JS documents in Pyodide ensuring taht messages @@ -128,7 +165,17 @@ def pysync(event): def _link_docs_worker(doc: Document, dispatch_fn: Any, msg_id: str | None = None): """ Links the Python document to a dispatch_fn which can be used to - sync messages between a WebWorker and the main thread. + sync messages between a WebWorker and the main thread in the + browser. + + Arguments + --------- + doc: bokeh.document.Document + The document to dispatch messages from. + dispatch_fn: JS function + The Javascript function to dispatch messages to. + msg_id: str | None + An optional message ID to pass through to the dispatch_fn. """ def pysync(event): json_patch, buffers = process_document_events([event], use_buffers=True) @@ -150,9 +197,9 @@ async def _link_model(ref: str, doc: Document) -> None: Arguments --------- ref: str - The ID of the rendered Bokeh Model. + The ID of the rendered bokeh Model doc: bokeh.document.Document - The Bokeh Document to sync the rendered Model with. + The bokeh Document to sync the rendered Model with. """ from js import Bokeh rendered = Bokeh.index.object_keys() @@ -265,6 +312,7 @@ async def write_doc(doc: Document | None = None) -> Tuple[str, str, str]: Arguments --------- doc: Document + The document to render to JSON. Returns ------- @@ -295,3 +343,34 @@ async def write_doc(doc: Document | None = None) -> Tuple[str, str, str]: _link_docs(pydoc, jsdoc) hide_loader() return docs_json, render_items, root_ids + +def pyrender(code: str, msg_id: str): + """ + Executes Python code and returns a MIME representation of the + return value. + + Arguments + --------- + code: str + Python code to execute + msg_id: str + A unique ID associated with the output being rendered. + + Returns + ------- + Returns an JS Map containing the content, mime_type, stdout and stderr. + """ + from ..pane import panel as as_panel + from ..viewable import Viewable, Viewer + out, stdout, stderr = exec_with_return(code) + ret = { + 'stdout': stdout, + 'stderr': stderr + } + if isinstance(out, (Model, Viewable, Viewer)): + doc, model_json = _model_json(as_panel(out), msg_id) + state.cache[msg_id] = doc + ret['content'], ret['mime_type'] = model_json, 'application/bokeh' + elif out is not UNDEFINED: + ret['content'], ret['mime_type'] = format_mime(out) + return pyodide.ffi.to_js(ret) diff --git a/panel/tests/io/test_mime_render.py b/panel/tests/io/test_mime_render.py index d49879de39..1aa72962a0 100644 --- a/panel/tests/io/test_mime_render.py +++ b/panel/tests/io/test_mime_render.py @@ -1,10 +1,6 @@ -import json import pathlib -from bokeh.models import Slider - from panel.io.mime_render import UNDEFINED, exec_with_return, format_mime -from panel.widgets import FloatSlider class HTML: @@ -32,13 +28,26 @@ def _repr_png_(self): def test_exec_with_return_multi_line(): - assert exec_with_return('a = 1\nb = 2\na + b') == 3 + assert exec_with_return('a = 1\nb = 2\na + b') == (3, '', '') def test_exec_with_return_no_return(): - assert exec_with_return('a = 1') is UNDEFINED + assert exec_with_return('a = 1') == (UNDEFINED, '', '') def test_exec_with_return_None(): - assert exec_with_return('None') is None + assert exec_with_return('None') == (None, '', '') + +def test_exec_captures_print(): + assert exec_with_return('print("foo")') == (None, 'foo\n', '') + +def test_exec_captures_error(): + exc = ( + 'Traceback (most recent call last):\n' + ' File "/Users/philippjfr/development/panel/panel/io/mime_render.py", line 77, in exec_with_return\n' + ' exec(compile(last_ast, "", "exec"), global_context)\n' + ' File "", line 1, in \n' + 'ValueError: bar\n' + ) + assert exec_with_return('raise ValueError("bar")') == (UNDEFINED, '', exc) def test_format_mime_None(): assert format_mime(None) == ('None', 'text/plain') @@ -62,13 +71,3 @@ def test_format_mime_repr_png(): img, mime_type = format_mime(PNG()) assert mime_type == 'text/html' assert img.startswith('