Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Capture stdout and stderr in pyodide execution #3856

Merged
merged 1 commit into from
Sep 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 48 additions & 65 deletions panel/io/mime_render.py
Original file line number Diff line number Diff line change
@@ -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()

#---------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand All @@ -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, "<ast>", "exec"), global_context)
if type(last_ast.body[0]) == ast.Expr:
return eval(compile(_convert_expr(last_ast.body[0]), "<ast>", "eval"), global_context)
else:
exec(compile(last_ast, "<ast>", "exec"), global_context)
return UNDEFINED
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
try:
exec(compile(init_ast, "<ast>", "exec"), global_context)
if type(last_ast.body[0]) == ast.Expr:
out = eval(compile(_convert_expr(last_ast.body[0]), "<ast>", "eval"), global_context)
else:
exec(compile(last_ast, "<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",
Expand All @@ -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()])
Expand All @@ -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'<embed src="{src}" width="100%" height="100%" type="application/pdf">', '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.
Expand All @@ -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
Expand Down
89 changes: 84 additions & 5 deletions panel/io/pyodide.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,26 @@

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

from ..config import config
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
#---------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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
-------
Expand Down Expand Up @@ -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)
29 changes: 12 additions & 17 deletions panel/tests/io/test_mime_render.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -32,13 +28,22 @@ 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():
out, stdout, stderr = exec_with_return('raise ValueError("bar")')
assert out is UNDEFINED
assert stdout == ''
assert 'ValueError: bar' in stderr

def test_format_mime_None():
assert format_mime(None) == ('None', 'text/plain')
Expand All @@ -62,13 +67,3 @@ def test_format_mime_repr_png():
img, mime_type = format_mime(PNG())
assert mime_type == 'text/html'
assert img.startswith('<img src="data:image/png')

def test_format_mime_panel_obj():
model_json, mime_type = format_mime(FloatSlider())
assert mime_type == 'application/bokeh'
assert 'doc' in json.loads(model_json)

def test_format_mime_bokeh_obj():
model_json, mime_type = format_mime(Slider())
assert mime_type == 'application/bokeh'
assert 'doc' in json.loads(model_json)