Skip to content

Commit ab1fa75

Browse files
authored
Merge pull request #24 from jbweston/ipywidgets
add support for rendering ipywidgets
2 parents 00f219a + 62915bf commit ab1fa75

File tree

3 files changed

+148
-4
lines changed

3 files changed

+148
-4
lines changed

jupyter_sphinx/execute.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
from itertools import groupby, count
55
from operator import itemgetter
6+
import json
67

78
from sphinx.util import logging
89
from sphinx.transforms import SphinxTransform
@@ -23,11 +24,34 @@
2324

2425
import nbformat
2526

27+
from ipywidgets import Widget
28+
import ipywidgets.embed
2629

2730
from ._version import __version__
2831

32+
2933
logger = logging.getLogger(__name__)
3034

35+
WIDGET_VIEW_MIMETYPE = 'application/vnd.jupyter.widget-view+json'
36+
WIDGET_STATE_MIMETYPE = 'application/vnd.jupyter.widget-state+json'
37+
REQUIRE_URL_DEFAULT = 'https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js'
38+
39+
40+
def builder_inited(app):
41+
"""
42+
2 cases
43+
case 1: ipywidgets 7, with require
44+
case 2: ipywidgets 7, no require
45+
"""
46+
require_url = app.config.jupyter_sphinx_require_url
47+
if require_url:
48+
app.add_js_file(require_url)
49+
embed_url = app.config.jupyter_sphinx_embed_url or ipywidgets.embed.DEFAULT_EMBED_REQUIREJS_URL
50+
else:
51+
embed_url = app.config.jupyter_sphinx_embed_url or ipywidgets.embed.DEFAULT_EMBED_SCRIPT_URL
52+
if embed_url:
53+
app.add_js_file(embed_url)
54+
3155

3256
### Directives and their associated doctree nodes
3357

@@ -167,6 +191,47 @@ class JupyterCellNode(docutils.nodes.container):
167191
"""
168192

169193

194+
class JupyterWidgetViewNode(docutils.nodes.Element):
195+
"""Inserted into doctree whenever a Jupyter cell produces a widget as output.
196+
197+
Contains a unique ID for this widget; enough information for the widget
198+
embedding javascript to render it, given the widget state. For non-HTML
199+
outputs this doctree node is rendered generically.
200+
"""
201+
202+
def __init__(self, view_spec):
203+
super().__init__('', view_spec=view_spec)
204+
205+
def html(self):
206+
return ipywidgets.embed.widget_view_template.format(
207+
view_spec=json.dumps(self['view_spec']))
208+
209+
def text(self):
210+
return '[ widget ]'
211+
212+
213+
class JupyterWidgetStateNode(docutils.nodes.Element):
214+
"""Appended to doctree if any Jupyter cell produced a widget as output.
215+
216+
Contains the state needed to render a collection of Jupyter widgets.
217+
218+
Per doctree there is 1 JupyterWidgetStateNode per kernel that produced
219+
Jupyter widgets when running. This is fine as (presently) the
220+
'html-manager' Javascript library, which embeds widgets, loads the state
221+
from all script tags on the page of the correct mimetype.
222+
"""
223+
224+
def __init__(self, state):
225+
super().__init__('', state=state)
226+
227+
def html(self):
228+
# TODO: render into a separate file if 'html-manager' starts fully
229+
# parsing script tags, and not just grabbing their innerHTML
230+
# https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/html-manager/src/libembed.ts#L36
231+
return ipywidgets.embed.snippet_template.format(
232+
load='', widget_views='', json_data=json.dumps(self['state']))
233+
234+
170235
### Doctree transformations
171236

172237
class ExecuteJupyterCells(SphinxTransform):
@@ -246,6 +311,9 @@ def apply(self):
246311
)
247312
attach_outputs(output_nodes, node)
248313

314+
if contains_widgets(notebook):
315+
doctree.append(JupyterWidgetStateNode(get_widgets(notebook)))
316+
249317

250318
### Roles
251319

@@ -364,6 +432,8 @@ def cell_output_to_nodes(cell, data_priority, dir):
364432
text=data,
365433
rawsource=data,
366434
))
435+
elif mime_type == WIDGET_VIEW_MIMETYPE:
436+
to_add.append(JupyterWidgetViewNode(data))
367437

368438
return to_add
369439

@@ -398,6 +468,27 @@ def execute_cells(kernel_name, cells, execute_kwargs):
398468
return notebook
399469

400470

471+
def get_widgets(notebook):
472+
try:
473+
return notebook.metadata.widgets[WIDGET_STATE_MIMETYPE]
474+
except AttributeError:
475+
# Don't catch KeyError, as it's a bug if 'widgets' does
476+
# not contain 'WIDGET_STATE_MIMETYPE'
477+
return None
478+
479+
480+
def contains_widgets(notebook):
481+
widgets = get_widgets(notebook)
482+
return widgets and widgets['state']
483+
484+
485+
def language_info(executor):
486+
# Can only run this function inside 'setup_preprocessor'
487+
assert hasattr(executor, 'kc')
488+
info_msg = executor._wait_for_reply(executor.kc.kernel_info())
489+
return info_msg['content']['language_info']
490+
491+
401492
def write_notebook_output(notebook, output_dir, notebook_name):
402493
"""Extract output from notebook cells and write to files in output_dir.
403494
@@ -455,7 +546,7 @@ def setup(app):
455546
# Configuration
456547
app.add_config_value(
457548
'jupyter_execute_kwargs',
458-
dict(timeout=-1, allow_errors=True),
549+
dict(timeout=-1, allow_errors=True, store_widget_state=True),
459550
'env'
460551
)
461552
app.add_config_value(
@@ -466,6 +557,7 @@ def setup(app):
466557
app.add_config_value(
467558
'jupyter_execute_data_priority',
468559
[
560+
WIDGET_VIEW_MIMETYPE,
469561
'text/html',
470562
'image/svg+xml',
471563
'image/png',
@@ -476,6 +568,10 @@ def setup(app):
476568
'env',
477569
)
478570

571+
# ipywidgets config
572+
app.add_config_value('jupyter_sphinx_require_url', REQUIRE_URL_DEFAULT, 'html')
573+
app.add_config_value('jupyter_sphinx_embed_url', None, 'html')
574+
479575
# JupyterKernelNode is just a doctree marker for the
480576
# ExecuteJupyterCells transform, so we don't actually render it.
481577
def skip(self, node):
@@ -507,6 +603,35 @@ def skip(self, node):
507603
man=render_container,
508604
)
509605

606+
# JupyterWidgetViewNode holds widget view JSON,
607+
# but is only rendered properly in HTML documents.
608+
def visit_widget_html(self, node):
609+
self.body.append(node.html())
610+
raise docutils.nodes.SkipNode
611+
612+
def visit_widget_text(self, node):
613+
self.body.append(node.text())
614+
raise docutils.nodes.SkipNode
615+
616+
app.add_node(
617+
JupyterWidgetViewNode,
618+
html=(visit_widget_html, None),
619+
latex=(visit_widget_text, None),
620+
textinfo=(visit_widget_text, None),
621+
text=(visit_widget_text, None),
622+
man=(visit_widget_text, None),
623+
)
624+
# JupyterWidgetStateNode holds the widget state JSON,
625+
# but is only rendered in HTML documents.
626+
app.add_node(
627+
JupyterWidgetStateNode,
628+
html=(visit_widget_html, None),
629+
latex=(skip, None),
630+
textinfo=(skip, None),
631+
text=(skip, None),
632+
man=(skip, None),
633+
)
634+
510635
app.add_directive('jupyter-execute', JupyterCell)
511636
app.add_directive('jupyter-kernel', JupyterKernel)
512637
app.add_role('jupyter-download:notebook', jupyter_download_role)
@@ -517,6 +642,8 @@ def skip(self, node):
517642
app.add_lexer('ipythontb', IPythonTracebackLexer())
518643
app.add_lexer('ipython', IPython3Lexer())
519644

645+
app.connect('builder-inited', builder_inited)
646+
520647
return {
521648
'version': __version__,
522649
'parallel_read_safe': True,

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
packages = ['jupyter_sphinx'],
1919
install_requires = [
2020
'Sphinx>=0.6',
21-
'ipywidgets>=6.0.0',
21+
'ipywidgets>=7.0.0',
2222
'IPython',
23-
'nbconvert>=5.4',
23+
'nbconvert>=5.5',
2424
'nbformat',
2525
],
2626
)

tests/test_execute.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99

1010
import pytest
1111

12-
from jupyter_sphinx.execute import JupyterCellNode, JupyterKernelNode
12+
from jupyter_sphinx.execute import (
13+
JupyterCellNode,
14+
JupyterKernelNode,
15+
JupyterWidgetViewNode,
16+
JupyterWidgetStateNode,
17+
)
1318

1419
@pytest.fixture()
1520
def doctree():
@@ -160,3 +165,15 @@ def test_raises(doctree):
160165
tree = doctree(source)
161166
cell, = tree.traverse(JupyterCellNode)
162167
'ValueError' in cell.children[1].rawsource
168+
169+
170+
def test_widgets(doctree):
171+
source = '''
172+
.. jupyter-execute::
173+
174+
import ipywidgets
175+
ipywidgets.Button()
176+
'''
177+
tree = doctree(source)
178+
assert len(list(tree.traverse(JupyterWidgetViewNode))) == 1
179+
assert len(list(tree.traverse(JupyterWidgetStateNode))) == 1

0 commit comments

Comments
 (0)