Skip to content

Commit 38d8632

Browse files
committed
first steps to a post-transform step for the AST
1 parent bd83f27 commit 38d8632

File tree

4 files changed

+160
-48
lines changed

4 files changed

+160
-48
lines changed

jupyter_sphinx/__init__.py

Lines changed: 36 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,11 @@
3439
def skip(self, node):
3540
raise docutils.nodes.SkipNode
3641

42+
# Used for nodes that should be gone by rendering time (OutputMimeBundleNode)
43+
def halt(self, node):
44+
raise ExtensionError((f"Rendering encountered a node type that should "
45+
"have been removed before rendering: {type(node)}"))
46+
3747
# Renders the children of a container
3848
render_container = (
3949
lambda self, node: self.visit_container(node),
@@ -45,22 +55,26 @@ def visit_container_html(self, node):
4555
self.body.append(node.visit_html())
4656
self.visit_container(node)
4757

58+
4859
def depart_container_html(self, node):
4960
self.depart_container(node)
5061
self.body.append(node.depart_html())
5162

63+
5264
# Used to render an element node as HTML
5365
def visit_element_html(self, node):
5466
self.body.append(node.html())
5567
raise docutils.nodes.SkipNode
5668

69+
5770
# Used to render the ThebeSourceNode conditionally for non-HTML builders
5871
def visit_thebe_source(self, node):
5972
if node["hide_code"]:
6073
raise docutils.nodes.SkipNode
6174
else:
6275
self.visit_container(node)
6376

77+
6478
render_thebe_source = (
6579
visit_thebe_source,
6680
lambda self, node: self.depart_container(node),
@@ -170,15 +184,28 @@ def setup(app):
170184
man=(skip, None),
171185
)
172186

173-
# JupyterCellNode is a container that holds the input and
174-
# any output, so we render it as a container.
187+
# Register our container nodes, these should behave just like a regular container
188+
for node in [JupyterCellNode, CellInputNode, CellOutputNode]:
189+
app.add_node(
190+
node,
191+
override=True,
192+
html=(render_container),
193+
latex=(render_container),
194+
textinfo=(render_container),
195+
text=(render_container),
196+
man=(render_container),
197+
)
198+
199+
# Register the output bundle node.
200+
# No translators should touch this node because we'll replace it in a post-transform
175201
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,
202+
CellOutputBundleNode,
203+
override=True,
204+
html=(halt, None),
205+
latex=(halt, None),
206+
textinfo=(halt, None),
207+
text=(halt, None),
208+
man=(halt, None),
182209
)
183210

184211
# JupyterWidgetViewNode holds widget view JSON,
@@ -242,6 +269,7 @@ def setup(app):
242269
app.add_role("jupyter-download:notebook", jupyter_download_role)
243270
app.add_role("jupyter-download:script", jupyter_download_role)
244271
app.add_transform(ExecuteJupyterCells)
272+
app.add_post_transform(CellOutputsToNodes)
245273

246274
# For syntax highlighting
247275
app.add_lexer("ipythontb", IPythonTracebackLexer())

jupyter_sphinx/ast.py

Lines changed: 118 additions & 30 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":
@@ -270,6 +303,9 @@ def cell_output_to_nodes(cell, data_priority, write_stderr, dir, thebe_config):
270303
continue
271304
data = output["data"][mime_type]
272305
if mime_type.startswith("image"):
306+
####################################
307+
# TODO: Figure out how to handle either inline or absolute image paths
308+
273309
# Sphinx treats absolute paths as being rooted at the source
274310
# directory, so make a relative path, which Sphinx treats
275311
# as being relative to the current working directory.
@@ -326,32 +362,37 @@ def cell_output_to_nodes(cell, data_priority, write_stderr, dir, thebe_config):
326362
def attach_outputs(output_nodes, node, thebe_config, cm_language):
327363
if not node.attributes["hide_code"]: # only add css if code is displayed
328364
node.attributes["classes"] = ["jupyter_container"]
365+
366+
input_node = _return_first_node_type(node, CellInputNode)
367+
outputbundle_node = _return_first_node_type(node, CellOutputBundleNode)
368+
output_node = CellOutputNode(classes=["cell_output"])
329369
if thebe_config:
330-
source = node.children[0]
370+
# Move the source from the input node into the thebe_source node
371+
source = input_node.children.pop(0)
331372
thebe_source = ThebeSourceNode(
332373
hide_code=node.attributes["hide_code"],
333374
code_below=node.attributes["code_below"],
334375
language=cm_language,
335376
)
336377
thebe_source.children = [source]
337-
338-
node.children = [thebe_source]
378+
input_node.children = [thebe_source]
339379

340380
if not node.attributes["hide_output"]:
341381
thebe_output = ThebeOutputNode()
342382
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]
383+
output_node += thebe_output
347384
else:
348385
if node.attributes["hide_code"]:
349-
node.children = []
386+
node.children.pop(0)
350387
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
388+
output_node.children = output_nodes
389+
390+
# Now replace the bundle with our OutputNode
391+
outputbundle_node.replace_self(output_node)
392+
393+
# Swap inputs and outputs if we want the code below
394+
if node.attributes["code_below"]:
395+
node.children = node.children[::-1]
355396

356397

357398
def jupyter_download_role(name, rawtext, text, lineno, inliner):
@@ -373,3 +414,50 @@ def get_widgets(notebook):
373414
# Don't catch KeyError, as it's a bug if 'widgets' does
374415
# not contain 'WIDGET_STATE_MIMETYPE'
375416
return None
417+
418+
419+
class CellOutputsToNodes(SphinxTransform):
420+
"""Use the builder context to transform a CellOutputNode into Sphinx nodes."""
421+
422+
default_priority = 700
423+
424+
def apply(self):
425+
thebe_config = self.config.jupyter_sphinx_thebelab_config
426+
427+
for cell_node in self.document.traverse(JupyterCellNode):
428+
output_bundle_node = _return_first_node_type(
429+
cell_node, CellOutputBundleNode
430+
)
431+
# Create doctree nodes for cell outputs.
432+
output_nodes = cell_output_to_nodes(
433+
output_bundle_node.outputs,
434+
self.config.jupyter_execute_data_priority,
435+
bool(cell_node.attributes["stderr"]),
436+
sphinx_abs_dir(self.env),
437+
thebe_config,
438+
)
439+
# Remove the outputbundlenode and we'll attach the outputs next
440+
attach_outputs(output_nodes, cell_node, thebe_config, cell_node.cm_language)
441+
442+
# Image collect extra nodes from cell outputs that we need to process
443+
for node in self.document.traverse(image):
444+
# If the image node has `candidates` then it's already been processed
445+
# as in-line markdown, so skip it
446+
if "candidates" in node:
447+
continue
448+
col = ImageCollector()
449+
col.process_doc(self.app, node)
450+
451+
452+
def _return_first_node_type(node, node_type):
453+
found_nodes = list(node.traverse(node_type))
454+
if len(found_nodes) == 0:
455+
raise ValueError(f"Found no nodes of type {node_type} in node {node}")
456+
if len(found_nodes) > 1:
457+
raise ValueError(
458+
(
459+
f"Found more than one nodes of type {node_type} in node {node}. "
460+
"only return the first instance"
461+
)
462+
)
463+
return found_nodes[0]

jupyter_sphinx/css/jupyter-sphinx.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ div.jupyter_container {
5858
div.jupyter_container div.highlight {
5959
background-color: #f7f7f7; /* for haiku */
6060
}
61-
div.jupyter_container {
61+
div.jupyter_container * {
6262
padding: 0;
6363
margin: 0;
6464
}

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)