Skip to content

Commit 0b34596

Browse files
authored
Merge pull request #107 from choldgraf/ast_transform
Using a post-transform for the AST
2 parents bd83f27 + 37be4df commit 0b34596

File tree

5 files changed

+237
-103
lines changed

5 files changed

+237
-103
lines changed

doc/source/index.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,11 @@ Styling options
336336
The CSS (Cascading Style Sheet) class structure of jupyter-sphinx is the
337337
following::
338338

339-
- jupyter_container
340-
- code_cell
341-
- stderr
342-
- output
339+
- jupyter_container, jupyter_cell
340+
- cell_input
341+
- cell_output
342+
- stderr
343+
- output
343344

344345
If a code cell is not displayed, the output is provided without the
345346
``jupyter_container``. If you want to adjust the styles, add a new stylesheet,

jupyter_sphinx/__init__.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@
66
import ipywidgets
77
import os
88
from sphinx.util.fileutil import copy_asset
9+
from sphinx.errors import ExtensionError
910
from IPython.lib.lexers import IPythonTracebackLexer, IPython3Lexer
1011

1112
from .ast import (
1213
JupyterCell,
1314
JupyterCellNode,
15+
CellInputNode,
16+
CellOutputNode,
17+
CellOutputBundleNode,
1418
JupyterKernelNode,
1519
JupyterWidgetViewNode,
1620
JupyterWidgetStateNode,
1721
WIDGET_VIEW_MIMETYPE,
1822
jupyter_download_role,
23+
CellOutputsToNodes,
1924
)
2025
from .execute import JupyterKernel, ExecuteJupyterCells
2126
from .thebelab import ThebeButton, ThebeButtonNode, ThebeOutputNode, ThebeSourceNode
@@ -34,6 +39,17 @@
3439
def skip(self, node):
3540
raise docutils.nodes.SkipNode
3641

42+
43+
# Used for nodes that should be gone by rendering time (OutputMimeBundleNode)
44+
def halt(self, node):
45+
raise ExtensionError(
46+
(
47+
"Rendering encountered a node type that should "
48+
"have been removed before rendering: %s" % type(node)
49+
)
50+
)
51+
52+
3753
# Renders the children of a container
3854
render_container = (
3955
lambda self, node: self.visit_container(node),
@@ -45,22 +61,26 @@ def visit_container_html(self, node):
4561
self.body.append(node.visit_html())
4662
self.visit_container(node)
4763

64+
4865
def depart_container_html(self, node):
4966
self.depart_container(node)
5067
self.body.append(node.depart_html())
5168

69+
5270
# Used to render an element node as HTML
5371
def visit_element_html(self, node):
5472
self.body.append(node.html())
5573
raise docutils.nodes.SkipNode
5674

75+
5776
# Used to render the ThebeSourceNode conditionally for non-HTML builders
5877
def visit_thebe_source(self, node):
5978
if node["hide_code"]:
6079
raise docutils.nodes.SkipNode
6180
else:
6281
self.visit_container(node)
6382

83+
6484
render_thebe_source = (
6585
visit_thebe_source,
6686
lambda self, node: self.depart_container(node),
@@ -170,15 +190,28 @@ def setup(app):
170190
man=(skip, None),
171191
)
172192

173-
# JupyterCellNode is a container that holds the input and
174-
# any output, so we render it as a container.
193+
# Register our container nodes, these should behave just like a regular container
194+
for node in [JupyterCellNode, CellInputNode, CellOutputNode]:
195+
app.add_node(
196+
node,
197+
override=True,
198+
html=(render_container),
199+
latex=(render_container),
200+
textinfo=(render_container),
201+
text=(render_container),
202+
man=(render_container),
203+
)
204+
205+
# Register the output bundle node.
206+
# No translators should touch this node because we'll replace it in a post-transform
175207
app.add_node(
176-
JupyterCellNode,
177-
html=render_container,
178-
latex=render_container,
179-
textinfo=render_container,
180-
text=render_container,
181-
man=render_container,
208+
CellOutputBundleNode,
209+
override=True,
210+
html=(halt, None),
211+
latex=(halt, None),
212+
textinfo=(halt, None),
213+
text=(halt, None),
214+
man=(halt, None),
182215
)
183216

184217
# JupyterWidgetViewNode holds widget view JSON,
@@ -242,6 +275,7 @@ def setup(app):
242275
app.add_role("jupyter-download:notebook", jupyter_download_role)
243276
app.add_role("jupyter-download:script", jupyter_download_role)
244277
app.add_transform(ExecuteJupyterCells)
278+
app.add_post_transform(CellOutputsToNodes)
245279

246280
# For syntax highlighting
247281
app.add_lexer("ipythontb", IPythonTracebackLexer())

jupyter_sphinx/ast.py

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55

66
import docutils
77
from docutils.parsers.rst import Directive, directives
8-
from docutils.nodes import math_block
8+
from docutils.nodes import math_block, image
99
from sphinx.util import parselinenos
1010
from sphinx.addnodes import download_reference
11+
from sphinx.transforms import SphinxTransform
12+
from sphinx.environment.collectors.asset import ImageCollector
1113

1214
import ipywidgets.embed
1315
import nbconvert
@@ -127,20 +129,24 @@ def run(self):
127129
else:
128130
hl_lines = []
129131

130-
return [
131-
JupyterCellNode(
132-
"",
133-
docutils.nodes.literal_block(text="\n".join(content)),
134-
hide_code=("hide-code" in self.options),
135-
hide_output=("hide-output" in self.options),
136-
code_below=("code-below" in self.options),
137-
linenos=("linenos" in self.options),
138-
linenostart=(self.options.get("lineno-start")),
139-
emphasize_lines=hl_lines,
140-
raises=self.options.get("raises"),
141-
stderr=("stderr" in self.options),
142-
)
143-
]
132+
# A top-level placeholder for our cell
133+
cell_node = JupyterCellNode(
134+
hide_code=("hide-code" in self.options),
135+
hide_output=("hide-output" in self.options),
136+
code_below=("code-below" in self.options),
137+
linenos=("linenos" in self.options),
138+
linenostart=(self.options.get("lineno-start")),
139+
emphasize_lines=hl_lines,
140+
raises=self.options.get("raises"),
141+
stderr=("stderr" in self.options),
142+
classes=["jupyter_cell"],
143+
)
144+
145+
# Add the input section of the cell, we'll add output at execution time
146+
cell_input = CellInputNode(classes=["cell_input"])
147+
cell_input += docutils.nodes.literal_block(text="\n".join(content))
148+
cell_node += cell_input
149+
return [cell_node]
144150

145151

146152
class JupyterCellNode(docutils.nodes.container):
@@ -151,6 +157,28 @@ class JupyterCellNode(docutils.nodes.container):
151157
"""
152158

153159

160+
class CellInputNode(docutils.nodes.container):
161+
"""Represent an input cell in the Sphinx AST."""
162+
163+
def __init__(self, rawsource="", *children, **attributes):
164+
super().__init__("", **attributes)
165+
166+
167+
class CellOutputNode(docutils.nodes.container):
168+
"""Represent an output cell in the Sphinx AST."""
169+
170+
def __init__(self, rawsource="", *children, **attributes):
171+
super().__init__("", **attributes)
172+
173+
174+
class CellOutputBundleNode(docutils.nodes.container):
175+
"""Represent a MimeBundle in the Sphinx AST, to be transformed later."""
176+
177+
def __init__(self, outputs, rawsource="", *children, **attributes):
178+
self.outputs = outputs
179+
super().__init__("", **attributes)
180+
181+
154182
class JupyterKernelNode(docutils.nodes.Element):
155183
"""Inserted into doctree whenever a JupyterKernel directive is encountered.
156184
@@ -199,12 +227,12 @@ def html(self):
199227
)
200228

201229

202-
def cell_output_to_nodes(cell, data_priority, write_stderr, dir, thebe_config):
230+
def cell_output_to_nodes(outputs, data_priority, write_stderr, dir, thebe_config):
203231
"""Convert a jupyter cell with outputs and filenames to doctree nodes.
204232
205233
Parameters
206234
----------
207-
cell : jupyter cell
235+
outputs : a list of outputs from a Jupyter cell
208236
data_priority : list of mime types
209237
Which media types to prioritize.
210238
write_stderr : bool
@@ -214,9 +242,14 @@ def cell_output_to_nodes(cell, data_priority, write_stderr, dir, thebe_config):
214242
to the source folder prefixed with ``/``.
215243
thebe_config: dict
216244
Thebelab configuration object or None
245+
246+
Returns
247+
-------
248+
to_add : list of docutils nodes
249+
Each output, converted into a docutils node.
217250
"""
218251
to_add = []
219-
for _, output in enumerate(cell.get("outputs", [])):
252+
for output in outputs:
220253
output_type = output["output_type"]
221254
if output_type == "stream":
222255
if output["name"] == "stderr":
@@ -325,33 +358,39 @@ def cell_output_to_nodes(cell, data_priority, write_stderr, dir, thebe_config):
325358

326359
def attach_outputs(output_nodes, node, thebe_config, cm_language):
327360
if not node.attributes["hide_code"]: # only add css if code is displayed
328-
node.attributes["classes"] = ["jupyter_container"]
361+
classes = node.attributes.get("classes", [])
362+
classes += ["jupyter_container"]
363+
364+
(input_node,) = node.traverse(CellInputNode)
365+
(outputbundle_node,) = node.traverse(CellOutputBundleNode)
366+
output_node = CellOutputNode(classes=["cell_output"])
329367
if thebe_config:
330-
source = node.children[0]
368+
# Move the source from the input node into the thebe_source node
369+
source = input_node.children.pop(0)
331370
thebe_source = ThebeSourceNode(
332371
hide_code=node.attributes["hide_code"],
333372
code_below=node.attributes["code_below"],
334373
language=cm_language,
335374
)
336375
thebe_source.children = [source]
337-
338-
node.children = [thebe_source]
376+
input_node.children = [thebe_source]
339377

340378
if not node.attributes["hide_output"]:
341379
thebe_output = ThebeOutputNode()
342380
thebe_output.children = output_nodes
343-
if node.attributes["code_below"]:
344-
node.children = [thebe_output] + node.children
345-
else:
346-
node.children = node.children + [thebe_output]
381+
output_node += thebe_output
347382
else:
348383
if node.attributes["hide_code"]:
349-
node.children = []
384+
node.children.pop(0)
350385
if not node.attributes["hide_output"]:
351-
if node.attributes["code_below"]:
352-
node.children = output_nodes + node.children
353-
else:
354-
node.children = node.children + output_nodes
386+
output_node.children = output_nodes
387+
388+
# Now replace the bundle with our OutputNode
389+
outputbundle_node.replace_self(output_node)
390+
391+
# Swap inputs and outputs if we want the code below
392+
if node.attributes["code_below"]:
393+
node.children = node.children[::-1]
355394

356395

357396
def jupyter_download_role(name, rawtext, text, lineno, inliner):
@@ -373,3 +412,37 @@ def get_widgets(notebook):
373412
# Don't catch KeyError, as it's a bug if 'widgets' does
374413
# not contain 'WIDGET_STATE_MIMETYPE'
375414
return None
415+
416+
417+
class CellOutputsToNodes(SphinxTransform):
418+
"""Use the builder context to transform a CellOutputNode into Sphinx nodes."""
419+
420+
default_priority = 700
421+
422+
def apply(self):
423+
thebe_config = self.config.jupyter_sphinx_thebelab_config
424+
425+
for cell_node in self.document.traverse(JupyterCellNode):
426+
(output_bundle_node,) = cell_node.traverse(CellOutputBundleNode)
427+
428+
# Create doctree nodes for cell outputs.
429+
output_nodes = cell_output_to_nodes(
430+
output_bundle_node.outputs,
431+
self.config.jupyter_execute_data_priority,
432+
bool(cell_node.attributes["stderr"]),
433+
sphinx_abs_dir(self.env),
434+
thebe_config,
435+
)
436+
# Remove the outputbundlenode and we'll attach the outputs next
437+
attach_outputs(output_nodes, cell_node, thebe_config, cell_node.cm_language)
438+
439+
# Image collect extra nodes from cell outputs that we need to process
440+
for node in self.document.traverse(image):
441+
# If the image node has `candidates` then it's already been processed
442+
# as in-line content, so skip it
443+
if "candidates" in node:
444+
continue
445+
# re-initialize an ImageCollector because the `app` imagecollector instance
446+
# is only available via event listeners.
447+
col = ImageCollector()
448+
col.process_doc(self.app, node)

jupyter_sphinx/execute.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
)
2828
from .ast import (
2929
JupyterCellNode,
30+
CellOutputBundleNode,
3031
JupyterKernelNode,
3132
cell_output_to_nodes,
3233
JupyterWidgetStateNode,
@@ -200,7 +201,7 @@ def apply(self):
200201
# Add code cell CSS class
201202
for node in nodes:
202203
source = node.children[0]
203-
source.attributes["classes"] = ["code_cell"]
204+
source.attributes["classes"].append("code_cell")
204205

205206
# Write certain cell outputs (e.g. images) to separate files, and
206207
# modify the metadata of the associated cells in 'notebook' to
@@ -211,17 +212,12 @@ def apply(self):
211212
cm_language = notebook.metadata.language_info.codemirror_mode.name
212213
except AttributeError:
213214
cm_language = notebook.metadata.kernelspec.language
215+
for node in nodes:
216+
node.cm_language = cm_language
214217

215218
# Add doctree nodes for cell outputs.
216219
for node, cell in zip(nodes, notebook.cells):
217-
output_nodes = cell_output_to_nodes(
218-
cell,
219-
self.config.jupyter_execute_data_priority,
220-
bool(node.attributes["stderr"]),
221-
sphinx_abs_dir(self.env),
222-
thebe_config,
223-
)
224-
attach_outputs(output_nodes, node, thebe_config, cm_language)
220+
node += CellOutputBundleNode(cell.outputs)
225221

226222
if contains_widgets(notebook):
227223
doctree.append(JupyterWidgetStateNode(state=get_widgets(notebook)))

0 commit comments

Comments
 (0)