Skip to content

Commit 28468f2

Browse files
authored
Merge pull request #141 from willsimmons1465/issue-140
Allow adding raw inputs and outputs
2 parents b37e551 + c8be7ac commit 28468f2

File tree

6 files changed

+348
-43
lines changed

6 files changed

+348
-43
lines changed

doc/source/index.rst

+37
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,43 @@ produces:
317317

318318
print("hello, world!", file=sys.stderr)
319319

320+
Manually forming Jupyter cells
321+
------------------------------
322+
323+
When showing code samples that are computationally expensive, access restricted resources, or have non-deterministic output, it can be preferable to not have them run every time you build. You can simply embed input code without executing it using the ``jupyter-input`` directive expected output with ``jupyter-output``::
324+
325+
.. jupyter-input::
326+
:linenos:
327+
328+
import time
329+
330+
def slow_print(str):
331+
time.sleep(4000) # Simulate an expensive process
332+
print(str)
333+
334+
slow_print("hello, world!")
335+
336+
.. jupyter-output::
337+
338+
hello, world!
339+
340+
produces:
341+
342+
.. jupyter-input::
343+
:linenos:
344+
345+
import time
346+
347+
def slow_print(str):
348+
time.sleep(4000) # Simulate an expensive process
349+
print(str)
350+
351+
slow_print("hello, world!")
352+
353+
.. jupyter-output::
354+
355+
hello, world!
356+
320357
Controlling the execution environment
321358
-------------------------------------
322359
The execution environment can be controlled by using the ``jupyter-kernel`` directive. This directive takes

jupyter_sphinx/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
1313
from .ast import (
1414
JupyterCell,
1515
JupyterCellNode,
16+
CellInput,
1617
CellInputNode,
18+
CellOutput,
1719
CellOutputNode,
1820
CellOutputBundleNode,
1921
JupyterKernelNode,
2022
JupyterWidgetViewNode,
2123
JupyterWidgetStateNode,
2224
WIDGET_VIEW_MIMETYPE,
2325
JupyterDownloadRole,
26+
CombineCellInputOutput,
2427
CellOutputsToNodes,
2528
)
2629
from .execute import JupyterKernel, ExecuteJupyterCells
@@ -267,10 +270,13 @@ def setup(app):
267270

268271
app.add_directive("jupyter-execute", JupyterCell)
269272
app.add_directive("jupyter-kernel", JupyterKernel)
273+
app.add_directive("jupyter-input", CellInput)
274+
app.add_directive("jupyter-output", CellOutput)
270275
app.add_directive("thebe-button", ThebeButton)
271276
app.add_role("jupyter-download:notebook", JupyterDownloadRole())
272277
app.add_role("jupyter-download:nb", JupyterDownloadRole())
273278
app.add_role("jupyter-download:script", JupyterDownloadRole())
279+
app.add_transform(CombineCellInputOutput)
274280
app.add_transform(ExecuteJupyterCells)
275281
app.add_transform(CellOutputsToNodes)
276282

jupyter_sphinx/ast.py

+215-41
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sphinx.addnodes import download_reference
1313
from sphinx.transforms import SphinxTransform
1414
from sphinx.environment.collectors.asset import ImageCollector
15+
from sphinx.errors import ExtensionError
1516

1617
import ipywidgets.embed
1718
import nbconvert
@@ -27,6 +28,51 @@ def csv_option(s):
2728
return [p.strip() for p in s.split(",")] if s else []
2829

2930

31+
def load_content(cell, location, logger):
32+
if cell.arguments:
33+
# As per 'sphinx.directives.code.LiteralInclude'
34+
env = cell.state.document.settings.env
35+
rel_filename, filename = env.relfn2path(cell.arguments[0])
36+
env.note_dependency(rel_filename)
37+
if cell.content:
38+
logger.warning(
39+
'Ignoring inline code in Jupyter cell included from "{}"'.format(
40+
rel_filename
41+
),
42+
location=location,
43+
)
44+
try:
45+
with Path(filename).open() as f:
46+
content = [line.rstrip() for line in f.readlines()]
47+
except (IOError, OSError):
48+
raise IOError("File {} not found or reading it failed".format(filename))
49+
else:
50+
cell.assert_has_content()
51+
content = cell.content
52+
return content
53+
54+
55+
def get_highlights(cell, content, location, logger):
56+
# The code fragment is taken from CodeBlock directive almost unchanged:
57+
# https://github.com/sphinx-doc/sphinx/blob/0319faf8f1503453b6ce19020819a8cf44e39f13/sphinx/directives/code.py#L134-L148
58+
59+
emphasize_linespec = cell.options.get("emphasize-lines")
60+
if emphasize_linespec:
61+
nlines = len(content)
62+
hl_lines = parselinenos(emphasize_linespec, nlines)
63+
if any(i >= nlines for i in hl_lines):
64+
logger.warning(
65+
"Line number spec is out of range(1-{}): {}".format(
66+
nlines, emphasize_linespec
67+
),
68+
location=location,
69+
)
70+
hl_lines = [i + 1 for i in hl_lines if i < nlines]
71+
else:
72+
hl_lines = []
73+
return hl_lines
74+
75+
3076
class JupyterCell(Directive):
3177
"""Define a code cell to be later executed in a Jupyter kernel.
3278
@@ -89,50 +135,16 @@ def run(self):
89135

90136
location = self.state_machine.get_source_and_line(self.lineno)
91137

92-
if self.arguments:
93-
# As per 'sphinx.directives.code.LiteralInclude'
94-
env = self.state.document.settings.env
95-
rel_filename, filename = env.relfn2path(self.arguments[0])
96-
env.note_dependency(rel_filename)
97-
if self.content:
98-
logger.warning(
99-
'Ignoring inline code in Jupyter cell included from "{}"'.format(
100-
rel_filename
101-
),
102-
location=location,
103-
)
104-
try:
105-
with Path(filename).open() as f:
106-
content = [line.rstrip() for line in f.readlines()]
107-
except (IOError, OSError):
108-
raise IOError("File {} not found or reading it failed".format(filename))
109-
else:
110-
self.assert_has_content()
111-
content = self.content
112-
113-
# The code fragment is taken from CodeBlock directive almost unchanged:
114-
# https://github.com/sphinx-doc/sphinx/blob/0319faf8f1503453b6ce19020819a8cf44e39f13/sphinx/directives/code.py#L134-L148
115-
116-
emphasize_linespec = self.options.get("emphasize-lines")
117-
if emphasize_linespec:
118-
try:
119-
nlines = len(content)
120-
hl_lines = parselinenos(emphasize_linespec, nlines)
121-
if any(i >= nlines for i in hl_lines):
122-
logger.warning(
123-
"Line number spec is out of range(1-{}): {}".format(
124-
nlines, emphasize_linespec
125-
),
126-
location=location,
127-
)
128-
hl_lines = [i + 1 for i in hl_lines if i < nlines]
129-
except ValueError as err:
130-
return [self.state.document.reporter.warning(err, line=self.lineno)]
131-
else:
132-
hl_lines = []
138+
content = load_content(self, location, logger)
139+
140+
try:
141+
hl_lines = get_highlights(self, content, location, logger)
142+
except ValueError as err:
143+
return [self.state.document.reporter.warning(err, line=self.lineno)]
133144

134145
# A top-level placeholder for our cell
135146
cell_node = JupyterCellNode(
147+
execute=True,
136148
hide_code=("hide-code" in self.options),
137149
hide_output=("hide-output" in self.options),
138150
code_below=("code-below" in self.options),
@@ -152,6 +164,135 @@ def run(self):
152164
cell_node += cell_input
153165
return [cell_node]
154166

167+
class CellInput(Directive):
168+
"""Define a code cell to be included verbatim but not executed.
169+
170+
Arguments
171+
---------
172+
filename : str (optional)
173+
If provided, a path to a file containing code.
174+
175+
Options
176+
-------
177+
linenos : bool
178+
If provided, the code will be shown with line numbering.
179+
lineno-start: nonnegative int
180+
If provided, the code will be show with line numbering beginning from
181+
specified line.
182+
emphasize-lines : comma separated list of line numbers
183+
If provided, the specified lines will be highlighted.
184+
185+
Content
186+
-------
187+
code : str
188+
A code cell.
189+
"""
190+
191+
required_arguments = 0
192+
optional_arguments = 1
193+
final_argument_whitespace = True
194+
has_content = True
195+
196+
option_spec = {
197+
"linenos": directives.flag,
198+
"lineno-start": directives.nonnegative_int,
199+
"emphasize-lines": directives.unchanged_required,
200+
}
201+
202+
def run(self):
203+
# This only works lazily because the logger is inited by Sphinx
204+
from . import logger
205+
206+
location = self.state_machine.get_source_and_line(self.lineno)
207+
208+
content = load_content(self, location, logger)
209+
210+
try:
211+
hl_lines = get_highlights(self, content, location, logger)
212+
except ValueError as err:
213+
return [self.state.document.reporter.warning(err, line=self.lineno)]
214+
215+
# A top-level placeholder for our cell
216+
cell_node = JupyterCellNode(
217+
execute=False,
218+
hide_code=False,
219+
hide_output=True,
220+
code_below=False,
221+
emphasize_lines=hl_lines,
222+
raises=False,
223+
stderr=False,
224+
classes=["jupyter_cell"],
225+
)
226+
227+
# Add the input section of the cell, we'll add output when jupyter-execute cells are run
228+
cell_input = CellInputNode(classes=["cell_input"])
229+
cell_input += docutils.nodes.literal_block(
230+
text="\n".join(content),
231+
linenos=("linenos" in self.options),
232+
linenostart=(self.options.get("lineno-start")),
233+
)
234+
cell_node += cell_input
235+
return [cell_node]
236+
237+
class CellOutput(Directive):
238+
"""Define an output cell to be included verbatim.
239+
240+
Arguments
241+
---------
242+
filename : str (optional)
243+
If provided, a path to a file containing output.
244+
245+
Content
246+
-------
247+
code : str
248+
An output cell.
249+
"""
250+
251+
required_arguments = 0
252+
optional_arguments = 1
253+
final_argument_whitespace = True
254+
has_content = True
255+
256+
option_spec = {}
257+
258+
def run(self):
259+
# This only works lazily because the logger is inited by Sphinx
260+
from . import logger
261+
262+
location = self.state_machine.get_source_and_line(self.lineno)
263+
264+
content = load_content(self, location, logger)
265+
266+
# A top-level placeholder for our cell
267+
cell_node = JupyterCellNode(
268+
execute=False,
269+
hide_code=True,
270+
hide_output=False,
271+
code_below=False,
272+
emphasize_lines=[],
273+
raises=False,
274+
stderr=False,
275+
)
276+
277+
# Add a blank input and the given output to the cell
278+
cell_input = CellInputNode(classes=["cell_input"])
279+
cell_input += docutils.nodes.literal_block(
280+
text="",
281+
linenos=False,
282+
linenostart=None,
283+
)
284+
cell_node += cell_input
285+
content_str = "\n".join(content)
286+
cell_output = CellOutputNode(classes=["cell_output"])
287+
cell_output += docutils.nodes.literal_block(
288+
text=content_str,
289+
rawsource=content_str,
290+
language="none",
291+
classes=["output", "stream"],
292+
)
293+
cell_node += cell_output
294+
return [cell_node]
295+
155296

156297
class JupyterCellNode(docutils.nodes.container):
157298
"""Inserted into doctree whever a JupyterCell directive is encountered.
@@ -433,6 +574,39 @@ def get_widgets(notebook):
433574
return None
434575

435576

577+
class CombineCellInputOutput(SphinxTransform):
578+
"""Merge nodes from CellOutput with the preceding CellInput node."""
579+
580+
default_priority = 120
581+
582+
def apply(self):
583+
moved_outputs = set()
584+
585+
for cell_node in self.document.traverse(JupyterCellNode):
586+
if cell_node.attributes["execute"] == False:
587+
if cell_node.attributes["hide_code"] == False:
588+
# Cell came from jupyter-input
589+
sibling = cell_node.next_node(descend=False, siblings=True)
590+
if (
591+
isinstance(sibling, JupyterCellNode)
592+
and sibling.attributes["execute"] == False
593+
and sibling.attributes["hide_code"] == True
594+
):
595+
# Sibling came from jupyter-output, so we merge
596+
cell_node += sibling.children[1]
597+
cell_node.attributes["hide_output"] = False
598+
moved_outputs.update({sibling})
599+
else:
600+
# Call came from jupyter-output
601+
if cell_node not in moved_outputs:
602+
raise ExtensionError(
603+
"Found a jupyter-output node without a preceding jupyter-input"
604+
)
605+
606+
for output_node in moved_outputs:
607+
output_node.replace_self([])
608+
609+
436610
class CellOutputsToNodes(SphinxTransform):
437611
"""Use the builder context to transform a CellOutputNode into Sphinx nodes."""
438612

jupyter_sphinx/execute.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,11 @@ def apply(self):
147147
kernel_name = default_kernel
148148
file_name = next(default_names)
149149

150+
# Add empty placeholder cells for non-executed nodes so nodes and cells can be zipped
151+
# and the provided input/output can be inserted later
150152
notebook = execute_cells(
151153
kernel_name,
152-
[nbformat.v4.new_code_cell(node.astext()) for node in nodes],
154+
[nbformat.v4.new_code_cell(node.astext() if node["execute"] else "") for node in nodes],
153155
self.config.jupyter_execute_kwargs,
154156
)
155157

@@ -185,6 +187,16 @@ def apply(self):
185187
"Cell printed to stderr:\n{}".format(stderr[0]["text"])
186188
)
187189

190+
# Insert input/output into placeholders for non-executed cells
191+
for node, cell in zip(nodes, notebook.cells):
192+
if not node["execute"]:
193+
cell.source = node.children[0].astext()
194+
if len(node.children) == 2:
195+
output = nbformat.v4.new_output("stream")
196+
output.text = node.children[1].astext()
197+
cell.outputs = [output]
198+
node.children.pop()
199+
188200
try:
189201
lexer = notebook.metadata.language_info.pygments_lexer
190202
except AttributeError:

0 commit comments

Comments
 (0)