Skip to content

Commit 9e9cf80

Browse files
committed
refactor and add documentation.
1 parent 99a6909 commit 9e9cf80

File tree

1 file changed

+190
-112
lines changed

1 file changed

+190
-112
lines changed

jupyter_sphinx/execute.py

+190-112
Original file line numberDiff line numberDiff line change
@@ -29,46 +29,28 @@
2929
logger = logging.getLogger(__name__)
3030

3131

32-
def blank_nb(kernel_name):
33-
try:
34-
spec = get_kernel_spec(kernel_name)
35-
except NoSuchKernel as e:
36-
raise ExtensionError('Unable to find kernel', orig_exc=e)
37-
return nbformat.v4.new_notebook(metadata={
38-
'kernelspec': {
39-
'display_name': spec.display_name,
40-
'language': spec.language,
41-
'name': kernel_name,
42-
}
43-
})
44-
45-
46-
def split_on(pred, it):
47-
"""Split an iterator wherever a predicate is True."""
48-
49-
counter = 0
50-
51-
def count(x):
52-
nonlocal counter
53-
if pred(x):
54-
counter += 1
55-
return counter
56-
57-
# Return iterable of lists to ensure that we don't lose our
58-
# place in the iterator
59-
return (list(x) for _, x in groupby(it, count))
60-
61-
62-
class Cell(docutils.nodes.container):
63-
"""Container for input/output from Jupyter kernel"""
64-
pass
65-
66-
class KernelNode(docutils.nodes.Element):
67-
"""Dummy node for signaling a new kernel"""
68-
pass
69-
32+
### Directives and their associated doctree nodes
7033

7134
class JupyterKernel(Directive):
35+
"""Specify a new Jupyter Kernel.
36+
37+
Arguments
38+
---------
39+
kernel_name : str (optional)
40+
The name of the kernel in which to execute future Jupyter cells, as
41+
reported by executing 'jupyter kernelspec list' on the command line.
42+
43+
Options
44+
-------
45+
id : str
46+
An identifier for *this kernel instance*. Used to name any output
47+
files generated when executing the Jupyter cells (e.g. images
48+
produced by cells, or a script containing the cell inputs).
49+
50+
Content
51+
-------
52+
None
53+
"""
7254

7355
optional_arguments = 1
7456
final_argument_whitespace = False
@@ -79,15 +61,53 @@ class JupyterKernel(Directive):
7961
}
8062

8163
def run(self):
82-
kernel_name = self.arguments[0] if self.arguments else ''
83-
return [KernelNode(
64+
return [JupyterKernelNode(
65+
kernel_name=self.arguments[0] if self.arguments else '',
66+
kernel_id=self.options.get('id', ''),
67+
)]
68+
69+
70+
class JupyterKernelNode(docutils.nodes.Element):
71+
"""Inserted into doctree whenever a JupyterKernel directive is encountered.
72+
73+
Used as a marker to signal that the following JupyterCellNodes (until the
74+
next, if any, JupyterKernelNode) should be executed in a separate kernel.
75+
"""
76+
77+
def __init__(self, kernel_name, kernel_id):
78+
super().__init__(
8479
'',
8580
kernel_name=kernel_name.strip(),
86-
kernel_id=self.options.get('id', '').strip(),
87-
)]
81+
kernel_id=kernel_id.strip(),
82+
)
8883

8984

9085
class JupyterCell(Directive):
86+
"""Define a code cell to be later executed in a Jupyter kernel.
87+
88+
The content of the directive is the code to execute. Code is not
89+
executed when the directive is parsed, but later during a doctree
90+
transformation.
91+
92+
Arguments
93+
---------
94+
filename : str (optional)
95+
If provided, a path to a file containing code.
96+
97+
Options
98+
-------
99+
hide-code : bool
100+
If provided, the code will not be displayed in the output.
101+
hide-output : bool
102+
If provided, the cell output will not be displayed in the output.
103+
code-below : bool
104+
If provided, the code will be shown below the cell output.
105+
106+
Content
107+
-------
108+
code : str
109+
A code cell.
110+
"""
91111

92112
required_arguments = 0
93113
optional_arguments = 1
@@ -122,17 +142,98 @@ def run(self):
122142
self.assert_has_content()
123143
content = self.content
124144

125-
# Cell only contains the input for now; we will execute the cell
126-
# and insert the output when the whole document has been parsed.
127-
return [Cell('',
145+
return [JupyterCellNode(content, self.options)]
146+
147+
148+
class JupyterCellNode(docutils.nodes.container):
149+
"""Inserted into doctree whever a JupyterKernel directive is encountered.
150+
151+
Used as a marker to signal that the following JupyterCellNodes (until the
152+
next, if any, JupyterKernelNode) should be executed in a separate kernel.
153+
"""
154+
155+
def __init__(self, source_lines, options):
156+
return super().__init__(
157+
'',
128158
docutils.nodes.literal_block(
129-
text='\n'.join(content),
159+
text='\n'.join(source_lines),
130160
),
131-
hide_code=('hide-code' in self.options),
132-
hide_output=('hide-output' in self.options),
133-
code_below=('code-below' in self.options),
134-
)]
161+
hide_code=('hide-code' in options),
162+
hide_output=('hide-output' in options),
163+
code_below=('code-below' in options),
164+
)
165+
166+
167+
### Doctree transformations
135168

169+
class ExecuteJupyterCells(SphinxTransform):
170+
"""Execute code cells in Jupyter kernels.
171+
172+
Traverses the doctree to find JupyterKernel and JupyterCell nodes,
173+
then executes the code in the JupyterCell nodes in sequence, starting
174+
a new kernel every time a JupyterKernel node is encountered. The output
175+
from each code cell is inserted into the doctree.
176+
"""
177+
default_priority = 180 # An early transform, idk
178+
179+
def apply(self):
180+
doctree = self.document
181+
doc_relpath = os.path.dirname(self.env.docname) # relative to src dir
182+
docname = os.path.basename(self.env.docname)
183+
default_kernel = self.config.jupyter_execute_default_kernel
184+
default_names = default_notebook_names(docname)
185+
186+
# Check if we have anything to execute.
187+
if not doctree.traverse(JupyterCellNode):
188+
return
189+
190+
logger.info('executing {}'.format(docname))
191+
output_dir = os.path.join(output_directory(self.env), doc_relpath)
192+
193+
# Start new notebook whenever a JupyterKernelNode is encountered
194+
jupyter_nodes = (JupyterCellNode, JupyterKernelNode)
195+
nodes_by_notebook = split_on(
196+
lambda n: isinstance(n, JupyterKernelNode),
197+
doctree.traverse(lambda n: isinstance(n, jupyter_nodes))
198+
)
199+
200+
for first, *nodes in nodes_by_notebook:
201+
if isinstance(first, JupyterKernelNode):
202+
kernel_name = first['kernel_name'] or default_kernel
203+
file_name = first['kernel_id'] or next(default_names)
204+
else:
205+
nodes = (first, *nodes)
206+
kernel_name = default_kernel
207+
file_name = next(default_names)
208+
209+
notebook = execute_cells(
210+
kernel_name,
211+
[nbformat.v4.new_code_cell(node.astext()) for node in nodes],
212+
self.config.jupyter_execute_kwargs,
213+
)
214+
215+
# Highlight the code cells now that we know what language they are
216+
for node in nodes:
217+
source = node.children[0]
218+
lexer = notebook.metadata.language_info.pygments_lexer
219+
source.attributes['language'] = lexer
220+
221+
# Write certain cell outputs (e.g. images) to separate files, and
222+
# modify the metadata of the associated cells in 'notebook' to
223+
# include the path to the output file.
224+
write_notebook_output(notebook, output_dir, file_name)
225+
226+
# Add doctree nodes for cell outputs.
227+
for node, cell in zip(nodes, notebook.cells):
228+
output_nodes = cell_output_to_nodes(
229+
cell,
230+
self.config.jupyter_execute_data_priority,
231+
sphinx_abs_dir(self.env)
232+
)
233+
attach_outputs(output_nodes, node)
234+
235+
236+
### Roles
136237

137238
def jupyter_download_role(name, rawtext, text, lineno, inliner):
138239
_, filetype = name.split(':')
@@ -147,11 +248,43 @@ def jupyter_download_role(name, rawtext, text, lineno, inliner):
147248
return [node], []
148249

149250

251+
### Utilities
252+
253+
def blank_nb(kernel_name):
254+
try:
255+
spec = get_kernel_spec(kernel_name)
256+
except NoSuchKernel as e:
257+
raise ExtensionError('Unable to find kernel', orig_exc=e)
258+
return nbformat.v4.new_notebook(metadata={
259+
'kernelspec': {
260+
'display_name': spec.display_name,
261+
'language': spec.language,
262+
'name': kernel_name,
263+
}
264+
})
265+
266+
267+
def split_on(pred, it):
268+
"""Split an iterator wherever a predicate is True."""
269+
270+
counter = 0
271+
272+
def count(x):
273+
nonlocal counter
274+
if pred(x):
275+
counter += 1
276+
return counter
277+
278+
# Return iterable of lists to ensure that we don't lose our
279+
# place in the iterator
280+
return (list(x) for _, x in groupby(it, count))
281+
282+
150283
def cell_output_to_nodes(cell, data_priority, dir):
151284
"""Convert a jupyter cell with outputs and filenames to doctree nodes.
152285
153286
Parameters
154-
==========
287+
----------
155288
cell : jupyter cell
156289
data_priority : list of mime types
157290
Which media types to prioritize.
@@ -304,63 +437,6 @@ def sphinx_abs_dir(env):
304437
)
305438

306439

307-
class ExecuteJupyterCells(SphinxTransform):
308-
default_priority = 180 # An early transform, idk
309-
310-
def apply(self):
311-
doctree = self.document
312-
doc_relpath = os.path.dirname(self.env.docname) # relative to src dir
313-
docname = os.path.basename(self.env.docname)
314-
default_kernel = self.config.jupyter_execute_default_kernel
315-
default_names = default_notebook_names(docname)
316-
317-
# Check if we have anything to execute.
318-
if not doctree.traverse(Cell):
319-
return
320-
321-
logger.info('executing {}'.format(docname))
322-
output_dir = os.path.join(output_directory(self.env), doc_relpath)
323-
324-
# Start new notebook whenever a KernelNode is encountered
325-
nodes_by_notebook = split_on(
326-
lambda n: isinstance(n, KernelNode),
327-
doctree.traverse(lambda n: isinstance(n, (Cell, KernelNode)))
328-
)
329-
330-
for first, *nodes in nodes_by_notebook:
331-
if isinstance(first, KernelNode):
332-
kernel_name = first['kernel_name'] or default_kernel
333-
file_name = first['kernel_id'] or next(default_names)
334-
else:
335-
nodes = (first, *nodes)
336-
kernel_name = default_kernel
337-
file_name = next(default_names)
338-
339-
notebook = execute_cells(
340-
kernel_name,
341-
[nbformat.v4.new_code_cell(node.astext()) for node in nodes],
342-
self.config.jupyter_execute_kwargs,
343-
)
344-
345-
for node in nodes:
346-
source = node.children[0]
347-
lexer = notebook.metadata.language_info.pygments_lexer
348-
source.attributes['language'] = lexer
349-
350-
# Modifies 'notebook' in-place, adding metadata specifying the
351-
# filenames of the saved outputs.
352-
write_notebook_output(notebook, output_dir, file_name)
353-
# Add doctree nodes for cell output; images reference the filenames
354-
# we just wrote to; sphinx copies these when writing outputs.
355-
for node, cell in zip(nodes, notebook.cells):
356-
output_nodes = cell_output_to_nodes(
357-
cell,
358-
self.config.jupyter_execute_data_priority,
359-
sphinx_abs_dir(self.env)
360-
)
361-
attach_outputs(output_nodes, node)
362-
363-
364440
def setup(app):
365441
# Configuration
366442
app.add_config_value(
@@ -386,13 +462,13 @@ def setup(app):
386462
'env',
387463
)
388464

389-
# KernelNode is just a doctree marker for the ExecuteJupyterCells
390-
# transform, so we don't actually render it.
465+
# JupyterKernelNode is just a doctree marker for the
466+
# ExecuteJupyterCells transform, so we don't actually render it.
391467
def skip(self, node):
392468
raise docutils.nodes.SkipNode
393469

394470
app.add_node(
395-
KernelNode,
471+
JupyterKernelNode,
396472
html=(skip, None),
397473
latex=(skip, None),
398474
textinfo=(skip, None),
@@ -401,13 +477,15 @@ def skip(self, node):
401477
)
402478

403479

480+
# JupyterCellNode is a container that holds the input and
481+
# any output, so we render it as a container.
404482
render_container = (
405483
lambda self, node: self.visit_container(node),
406484
lambda self, node: self.depart_container(node),
407485
)
408486

409487
app.add_node(
410-
Cell,
488+
JupyterCellNode,
411489
html=render_container,
412490
latex=render_container,
413491
textinfo=render_container,

0 commit comments

Comments
 (0)