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

WIP: Add browser rendered previews/snapshots of html and widgets #901

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5a9e4ee
Save widget model state to notebook metadata
ricklupton Dec 10, 2017
fcda672
#751 update widget state on execution
Mar 13, 2018
55019f8
#751 support for converting buffered data in widgets
Mar 15, 2018
9ea36ba
#751 handling widget metadata without model_name
Mar 15, 2018
0329f8a
#751 fixed python2 kernel output parsing for tests
Mar 22, 2018
1e8f0b0
#751 adding widget metadata moved to separate method
Mar 22, 2018
db9642b
fixes after rebases
maartenbreddels Oct 16, 2018
6fa801f
fix buffer support
maartenbreddels Oct 17, 2018
c8124cb
was using a dev version of ipywidgets
maartenbreddels Oct 29, 2018
9b88a2b
py27 fix
maartenbreddels Oct 29, 2018
6c00530
py27 fix, by not having an image for testing
maartenbreddels Oct 29, 2018
d3f60ac
fix: custom msg'es do not have state
maartenbreddels Nov 30, 2018
c540783
fix test: iinclude output in JupyterWidget.ipynb
maartenbreddels Nov 30, 2018
abb2e49
cleanups
maartenbreddels Nov 30, 2018
321a1ab
new features (snapshot): render output using a real browser and embed…
maartenbreddels Oct 16, 2018
df80466
do not enable by default, and fix typo
maartenbreddels Oct 16, 2018
e7e8025
allow missing output, and remove debugger breakpoint
maartenbreddels Oct 16, 2018
dc50e91
cleanups
maartenbreddels Oct 17, 2018
1e0f53d
refactor js part, and and fontawesome support
maartenbreddels Oct 17, 2018
23703c5
rebase this
maartenbreddels Oct 17, 2018
001b817
have a custom page opener (means support for chrome headless)
maartenbreddels Oct 17, 2018
68851ef
add tests
maartenbreddels Oct 17, 2018
756cdaa
initial commit
maartenbreddels Oct 20, 2018
d8d7347
install/build js
maartenbreddels Oct 20, 2018
6506df4
Add missing files
maartenbreddels Oct 20, 2018
04be3e1
run webpack
maartenbreddels Oct 26, 2018
ffb2219
allow require to wait longer
maartenbreddels Oct 26, 2018
c664bb3
support for bqplot and ipyleaflet
maartenbreddels Oct 26, 2018
6969498
generate js in setup.py and use resources package
maartenbreddels Oct 26, 2018
08f5c9e
small cleanup
maartenbreddels Oct 30, 2018
65164a4
escape spaces for running chrome on osx
maartenbreddels Oct 30, 2018
5f61777
fix: do not coalesce streams, it will cause a mismatch between output…
maartenbreddels Oct 30, 2018
f69184b
add devmode that avoids the browser caching js
maartenbreddels Nov 2, 2018
f467352
better shutdown of server, so we can reuse the port, see: https://git…
maartenbreddels Nov 2, 2018
ec1ff77
cleanup of the js code
maartenbreddels Nov 2, 2018
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
4 changes: 4 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# needs these two lines:
sudo: required
dist: trusty
addons:
chrome: stable

language: python
python:
Expand Down Expand Up @@ -32,6 +34,8 @@ install:
- pip install -f travis-wheels/wheelhouse . codecov coverage
- pip install nbconvert[execute,serve,test]
- pip install check-manifest
- npm install
- npm run build
- python -m ipykernel.kernelspec --user
script:
- check-manifest
Expand Down
1 change: 1 addition & 0 deletions nbconvert/exporters/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class Exporter(LoggingConfigurable):
'nbconvert.preprocessors.CSSHTMLHeaderPreprocessor',
'nbconvert.preprocessors.LatexPreprocessor',
'nbconvert.preprocessors.HighlightMagicsPreprocessor',
'nbconvert.preprocessors.SnapshotPreProcessor',
'nbconvert.preprocessors.ExtractOutputPreprocessor',
],
help="""List of preprocessors available by default, by name, namespace,
Expand Down
7 changes: 6 additions & 1 deletion nbconvert/nbconvertapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ def validate(self, obj, value):
{'ExecutePreprocessor' : {'enabled' : True}},
"Execute the notebook prior to export."
),
'snapshot' : (
{'SnapshotPreProcessor' : {'enabled' : True}},
"Create snapshots using a real browser"
),
'allow-errors' : (
{'ExecutePreprocessor' : {'allow_errors' : True}},
("Continue notebook execution even if one of the cells throws "
Expand Down Expand Up @@ -244,7 +248,8 @@ def _writer_class_changed(self, change):
help="""PostProcessor class used to write the
results of the conversion"""
).tag(config=True)
postprocessor_aliases = {'serve': 'nbconvert.postprocessors.serve.ServePostProcessor'}
postprocessor_aliases = {'serve': 'nbconvert.postprocessors.serve.ServePostProcessor',
'render': 'nbconvert.postprocessors.render.RenderPostProcessor'}
postprocessor_factory = Type(None, allow_none=True)

@observe('postprocessor_class')
Expand Down
1 change: 1 addition & 0 deletions nbconvert/preprocessors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .execute import ExecutePreprocessor, CellExecutionError
from .regexremove import RegexRemovePreprocessor
from .tagremove import TagRemovePreprocessor
from .snapshot import SnapshotPreProcessor

# decorated function Preprocessors
from .coalescestreams import coalesce_streams
50 changes: 49 additions & 1 deletion nbconvert/preprocessors/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.

import base64
from textwrap import dedent
from contextlib import contextmanager

Expand Down Expand Up @@ -299,6 +299,8 @@ def setup_preprocessor(self, nb, resources, km=None):
self.nb = nb
# clear display_id map
self._display_id_map = {}
self.widget_state = {}
self.widget_buffers = {}

if km is None:
self.km, self.kc = self.start_new_kernel(cwd=path)
Expand Down Expand Up @@ -361,9 +363,27 @@ def preprocess(self, nb, resources, km=None):
nb, resources = super(ExecutePreprocessor, self).preprocess(nb, resources)
info_msg = self._wait_for_reply(self.kc.kernel_info())
nb.metadata['language_info'] = info_msg['content']['language_info']
self.set_widgets_metadata()

return nb, resources

def set_widgets_metadata(self):
if self.widget_state:
self.nb.metadata.widgets = {
'application/vnd.jupyter.widget-state+json': {
'state': {
model_id: _serialize_widget_state(state)
for model_id, state in self.widget_state.items() if '_model_name' in state
},
'version_major': 2,
'version_minor': 0,
}
}
for key, widget in self.nb.metadata.widgets['application/vnd.jupyter.widget-state+json']['state'].items():
buffers = self.widget_buffers.get(key)
if buffers:
widget['buffers'] = buffers

def preprocess_cell(self, cell, resources, cell_index):
"""
Executes a single code cell. See base.py for details.
Expand Down Expand Up @@ -489,6 +509,11 @@ def run_cell(self, cell, cell_index=0):
cell_map[cell_index] = []
continue
elif msg_type.startswith('comm'):
data = content['data']
if 'state' in data: # ignore custom msg'es
self.widget_state.setdefault(content['comm_id'], {}).update(data['state'])
if 'buffer_paths' in data and data['buffer_paths']:
self.widget_buffers[content['comm_id']] = _get_buffer_data(msg)
continue

display_id = None
Expand Down Expand Up @@ -539,3 +564,26 @@ def executenb(nb, cwd=None, km=None, **kwargs):
resources['metadata'] = {'path': cwd}
ep = ExecutePreprocessor(**kwargs)
return ep.preprocess(nb, resources, km=km)[0]


def _serialize_widget_state(state):
"""Serialize a widget state, following format in @jupyter-widgets/schema."""
return {
'model_name': state.get('_model_name'),
'model_module': state.get('_model_module'),
'model_module_version': state.get('_model_module_version'),
'state': state,
}


def _get_buffer_data(msg):
encoded_buffers = []
paths = msg['content']['data']['buffer_paths']
buffers = msg['buffers']
for path, buffer in zip(paths, buffers):
encoded_buffers.append({
'data': base64.b64encode(buffer).decode('utf-8'),
'encoding': 'base64',
'path': path
})
return encoded_buffers
234 changes: 234 additions & 0 deletions nbconvert/preprocessors/snapshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"""PreProcessor for rendering """

# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

from __future__ import print_function

import os
import copy
import tempfile
import webbrowser
import threading
import tornado.escape
import nbformat

from traitlets.config.configurable import LoggingConfigurable
from tornado import web, ioloop, httpserver, log
from tornado.httpclient import AsyncHTTPClient
from traitlets import Bool, Unicode, Int, DottedObjectName, Type, observe, Instance
from traitlets.utils.importstring import import_item


from .base import Preprocessor
from ..exporters.html import HTMLExporter
from ..writers import FilesWriter


class SnapshotHandler(web.RequestHandler):
def initialize(self, snapshot_dict, callback):
self.snapshot_dict = snapshot_dict
self.callback = callback

# @web.asynchronous
def post(self, view_id=None, image_data=None):
#, view_id=None, image_data=None
#view_id = self.get_parameter('view_id')
data = tornado.escape.json_decode(self.request.body)
#print('hi', data['cell_index'], data['output_index'])
i, j = data['cell_index'], data['output_index']
key = i, j
image_data = data['image_data']
header = 'data:image/png;base64,'
assert image_data.startswith(header), 'not a png image?'
self.snapshot_dict[key]['data'][MIME_TYPE_PNG] = image_data[len(header):]
self.callback()


MIME_TYPE_JUPYTER_WIDGET_VIEW = 'application/vnd.jupyter.widget-view+json'
MIME_TYPE_PNG = 'image/png'
MIME_TYPE_HTML = 'text/html'
DIRNAME_STATIC = os.path.abspath(os.path.join(os.path.dirname(__file__), '../resources'))
def next_port():
i = 8009
while 1:
yield i
i += 1

next_port = next_port()

class PageOpener(LoggingConfigurable):
pass

class PageOpenerDefault(PageOpener):
browser = Unicode(u'',
help="""Specify what browser should be used to open slides. See
https://docs.python.org/3/library/webbrowser.html#webbrowser.register
to see how keys are mapped to browser executables. If
not specified, the default browser will be determined
by the `webbrowser`
standard library module, which allows setting of the BROWSER
environment variable to override it.
""").tag(config=True)

def open(self, url):
browser = webbrowser.get(self.browser or None)
b = lambda: browser.open(url, new=2)
threading.Thread(target=b).start()

chrome_binary = 'echo "not found"'
import platform
if platform.system().lower() == 'darwin':
chrome_binary = r"/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
elif platform.system().lower() == 'linux':
chrome_binary = 'google-chrome'




class PageOpenerChromeHeadless(PageOpener):
start_command = Unicode('%s --remote-debugging-port=9222 --headless &' % chrome_binary).tag(config=True)
def open(self, url):
import PyChromeDevTools
import requests.exceptions
import time
try:
chrome = PyChromeDevTools.ChromeInterface()
except requests.exceptions.ConnectionError:
print('could not connect, try starting with', self.start_command)
ret = os.system(self.start_command)
if ret != 0:
raise ValueError('could not start chrome headless with command: ' + self.start_command)
for i in range(4):
time.sleep(1)
print('try connecting to chrome')
try:
chrome = PyChromeDevTools.ChromeInterface()
except requests.exceptions.ConnectionError:
if i == 3:
raise
chrome.Network.enable()
chrome.Page.enable()
chrome.Page.navigate(url=url)


# chrome caches the js files, bad for development
class StaticFileHandlerNoCache(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')


class SnapshotPreProcessor(Preprocessor):
"""Pre processor that will make snapshots of widgets and html
"""

open_in_browser = Bool(True,
help="""Should the browser be opened automatically?"""
).tag(config=True)
devmode = Bool(True,
help="""In dev mode, the static files will not be cached by the browser"""
).tag(config=True)
keep_running = Bool(False, help="Keep server running when done").tag(config=True)
page_opener = Instance(PageOpener).tag(config=True)
page_opener_class = DottedObjectName('nbconvert.preprocessors.snapshot.PageOpenerChromeHeadless',
help="""How to open a page for rendering""").tag(config=True)
page_opener_aliases = {'headless': 'nbconvert.preprocessors.snapshot.PageOpenerChromeHeadless',
'default': 'nbconvert.preprocessors.snapshot.PageOpenerDefault'}
page_opener_factory = Type(allow_none=True)

@observe('page_opener_class')
def _page_opener_class_changed(self, change):
new = change['new']
if new.lower() in self.page_opener_aliases:
new = self.page_opener_aliases[new.lower()]
self.page_opener_factory = import_item(new)

ip = Unicode("127.0.0.1",
help="The IP address to listen on.").tag(config=True)
port = Int(8000, help="port for the server to listen on.").tag(config=True)

def callback(self):
done = True
for key, value in self.snapshot_dict.items():
if value['data'][MIME_TYPE_PNG] is None:
done = False

if done and not self.keep_running:
self.stop_server()

# see https://github.com/tornadoweb/tornado/issues/2523
def stop_server(self):
self.http_server.stop()
self.main_ioloop.add_future(self.http_server.close_all_connections(),
lambda x: self.main_ioloop.stop())

def preprocess(self, nb, resources):
"""Serve the build directory with a webserver."""
self.snapshot_dict = {}
self.nb = nb
for cell_index, cell in enumerate(self.nb.cells):
if 'outputs' in cell:
for output_index, output in enumerate(cell.outputs):
if 'data' in output:
if MIME_TYPE_JUPYTER_WIDGET_VIEW in output['data'] or MIME_TYPE_HTML in output['data']:
# clear the existing png data, we may consider skipping these cells
output['data'][MIME_TYPE_PNG] = None
self.snapshot_dict[(cell_index, output_index)] = output
if self.snapshot_dict.keys():
with tempfile.TemporaryDirectory() as dirname:
html_exporter = HTMLExporter(template_file='snapshot', default_preprocessors=[
'nbconvert.preprocessors.SVG2PDFPreprocessor',
'nbconvert.preprocessors.CSSHTMLHeaderPreprocessor',
'nbconvert.preprocessors.HighlightMagicsPreprocessor',
])
nbc = copy.deepcopy(nb)
resc = copy.deepcopy(resources)
output, resources_html = html_exporter.from_notebook_node(nbc, resources=resc)
writer = FilesWriter(build_directory=dirname)
filename_base = 'index'
filename = filename_base + '.html'
writer.write(output, resources_html, notebook_name=filename_base)

# dirname, filename = os.path.split(input)
handlers = [
(r"/send_snapshot", SnapshotHandler, dict(snapshot_dict=self.snapshot_dict, callback=self.callback)),
(r"/resources/(.+)", StaticFileHandlerNoCache if self.devmode else web.StaticFileHandler,
{'path' : DIRNAME_STATIC}),
(r"/(.+)", web.StaticFileHandler, {'path' : dirname}),
(r"/", web.RedirectHandler, {"url": "/%s" % filename})
]
app = web.Application(handlers,
client=AsyncHTTPClient(),
)

# hook up tornado logger to our logger
log.app_log = self.log

self.http_server = httpserver.HTTPServer(app)
self.http_server.listen(self.port, address=self.ip)
url = "http://%s:%i/%s" % (self.ip, self.port, filename)
print("Serving your slides at %s" % url)
print("Use Control-C to stop this server")
self.main_ioloop = ioloop.IOLoop.instance()
if self.open_in_browser:
self._page_opener_class_changed({ 'new': self.page_opener_class })
self.page_opener = self.page_opener_factory()
self.page_opener.open(url)
try:
self.main_ioloop.start()
except KeyboardInterrupt:
print("\nInterrupted")
self.http_server.stop()
# TODO: maybe we need to wait for this to finish
self.http_server.close_all_connections()
# nbformat.write(self.nb, input.replace('.html', '.ipynb'))
return nb, resources

def main(path):
"""allow running this module to serve the slides"""
server = SnapshotPreProcessor()
server(path)

if __name__ == '__main__':
import sys
main(sys.argv[1])
Loading