Skip to content

Commit f8a77cc

Browse files
steppimartinRenou
andauthored
Add try_examples directive for adding interactivity to sphinx Examples sections (#111)
* Add try_examples directive * Fix associated css for try_examples extension * Make global_enable_try_examples work * Make LaTeX processing work correctly * Fix rendering of Examples * Add support for multiline blocks in Examples * Make toggle examples <-> notebook work * Allow configuration of button css * Fix cases where no directive is inserted * Final tweaks * Get correct relative path to doc root * Allow leading whitespace in latex expression :math: * Handle edgecase, Examples is last section * Strip out content which should be ignored in notebook * Handle :Attributes: edge case for section header * Allow whitespace in processed by numpydoc part * Handle edgecase for processed by numpydoc * Handle case with multiple output lines * Fix incorrectly formatted arrays in output * Handle references in examples section -- replace href id with reference number * Reword some comments * Fix bugs in latex processing * Add global to global configuration var names * Add Python language to notebook metadata * Format code with black * Update jupyterlite_sphinx/jupyterlite_sphinx.js Co-authored-by: martinRenou <[email protected]> --------- Co-authored-by: martinRenou <[email protected]>
1 parent 7082bd1 commit f8a77cc

File tree

5 files changed

+531
-0
lines changed

5 files changed

+531
-0
lines changed
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import nbformat as nbf
2+
from nbformat.v4 import new_code_cell, new_markdown_cell
3+
import re
4+
5+
6+
def examples_to_notebook(input_lines):
7+
"""Parse examples section of a docstring and convert to Jupyter notebook.
8+
9+
Parameters
10+
----------
11+
input_lines : iterable of str.
12+
Lines within
13+
14+
Returns
15+
-------
16+
dict
17+
json for a Jupyter Notebook
18+
19+
Examples
20+
--------
21+
>>> from jupyterlite_sphinx.generate_notebook import examples_to_notebook
22+
23+
>>> input_lines = [
24+
>>> "Add two numbers. This block of text will appear as a\n",
25+
>>> "markdown cell. The following block will become a code\n",
26+
>>> "cell with the value 4 contained in the output.",
27+
>>> "\n",
28+
>>> ">>> x = 2\n",
29+
>>> ">>> y = 2\n",
30+
>>> ">>> x + y\n",
31+
>>> "4\n",
32+
>>> "\n",
33+
>>> "Inline LaTeX like :math:`x + y = 4` will be converted\n",
34+
>>> "correctly within markdown cells. As will block LaTeX\n",
35+
>>> "such as\n",
36+
>>> "\n",
37+
>>> ".. math::\n",
38+
>>> "\n",
39+
>>> " x = 2,\;y = 2
40+
>>> "\n",
41+
>>> " x + y = 4\n",
42+
>>> ]
43+
>>> notebook = examples_to_notebook(input_lines)
44+
"""
45+
nb = nbf.v4.new_notebook()
46+
47+
code_lines = []
48+
md_lines = []
49+
output_lines = []
50+
inside_multiline_code_block = False
51+
52+
ignore_directives = [".. plot::", ".. only::"]
53+
inside_ignore_directive = False
54+
55+
for line in input_lines:
56+
line = line.rstrip("\n")
57+
58+
# Content underneath some directives should be ignored when generating notebook.
59+
if any(line.startswith(directive) for directive in ignore_directives):
60+
inside_ignore_directive = True
61+
continue
62+
if inside_ignore_directive:
63+
if line == "" or line[0].isspace():
64+
continue
65+
else:
66+
inside_ignore_directive = False
67+
68+
if line.startswith(">>>"): # This is a code line.
69+
if output_lines:
70+
# If there are pending output lines, we must be starting a new
71+
# code block.
72+
_append_code_cell_and_clear_lines(code_lines, output_lines, nb)
73+
if inside_multiline_code_block:
74+
# A multiline codeblock is ending.
75+
inside_multiline_code_block = False
76+
# If there is any pending markdown text, add it to the notebook
77+
if md_lines:
78+
_append_markdown_cell_and_clear_lines(md_lines, nb)
79+
80+
# Add line of code, removing '>>> ' prefix
81+
code_lines.append(line[4:])
82+
elif line.startswith("...") and code_lines:
83+
# This is a line of code in a multiline code block.
84+
inside_multiline_code_block = True
85+
code_lines.append(line[4:])
86+
elif line.rstrip("\n") == "" and code_lines:
87+
# A blank line means a code block has ended.
88+
_append_code_cell_and_clear_lines(code_lines, output_lines, nb)
89+
elif code_lines:
90+
# Non-blank non ">>>" prefixed line must be output of previous code block.
91+
output_lines.append(line)
92+
else:
93+
# Anything else should be treated as markdown.
94+
md_lines.append(line)
95+
96+
# After processing all lines, add pending markdown or code to the notebook if
97+
# any exists.
98+
if md_lines:
99+
_append_markdown_cell_and_clear_lines(md_lines, nb)
100+
if code_lines:
101+
_append_code_cell_and_clear_lines(code_lines, output_lines, nb)
102+
103+
nb["metadata"] = {
104+
"kernelspec": {
105+
"display_name": "Python",
106+
"language": "python",
107+
"name": "python",
108+
},
109+
"language_info": {
110+
"name": "python",
111+
},
112+
}
113+
return nb
114+
115+
116+
def _append_code_cell_and_clear_lines(code_lines, output_lines, notebook):
117+
"""Append new code cell to notebook, clearing lines."""
118+
code_text = "\n".join(code_lines)
119+
cell = new_code_cell(code_text)
120+
if output_lines:
121+
combined_output = "\n".join(output_lines)
122+
cell.outputs.append(
123+
nbf.v4.new_output(
124+
output_type="execute_result",
125+
data={"text/plain": combined_output},
126+
),
127+
)
128+
notebook.cells.append(cell)
129+
output_lines.clear()
130+
code_lines.clear()
131+
132+
133+
def _append_markdown_cell_and_clear_lines(markdown_lines, notebook):
134+
"""Append new markdown cell to notebook, clearing lines."""
135+
markdown_text = "\n".join(markdown_lines)
136+
# Convert blocks of LaTeX equations
137+
markdown_text = _process_latex(markdown_text)
138+
markdown_text = _strip_ref_identifiers(markdown_text)
139+
notebook.cells.append(new_markdown_cell(markdown_text))
140+
markdown_lines.clear()
141+
142+
143+
_ref_identifier_pattern = re.compile(r"\[R[a-f0-9]+-(?P<ref_num>\d+)\]_")
144+
145+
146+
def _strip_ref_identifiers(md_text):
147+
"""Remove identifiers from references in notebook.
148+
149+
Each docstring gets a unique identifier in order to have unique internal
150+
links for each docstring on a page.
151+
152+
They look like [R4c2dbc17006a-1]_. We strip these out so they don't appear
153+
in the notebooks. The above would be replaced with [1]_.
154+
"""
155+
return _ref_identifier_pattern.sub(r"[\g<ref_num>]", md_text)
156+
157+
158+
def _process_latex(md_text):
159+
# Map rst latex directive to $ so latex renders in notebook.
160+
md_text = re.sub(
161+
r":math:\s*`(?P<latex>.*?)`", r"$\g<latex>$", md_text, flags=re.DOTALL
162+
)
163+
164+
lines = md_text.split("\n")
165+
in_math_block = False
166+
wrapped_lines = []
167+
equation_lines = []
168+
169+
for line in lines:
170+
if line.strip() == ".. math::":
171+
in_math_block = True
172+
continue # Skip the '.. math::' line
173+
174+
if in_math_block:
175+
if line.strip() == "":
176+
if equation_lines:
177+
# Join and wrap the equations, then reset
178+
wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$")
179+
equation_lines = []
180+
elif line.startswith(" ") or line.startswith("\t"):
181+
equation_lines.append(line.strip())
182+
else:
183+
wrapped_lines.append(line)
184+
185+
# If you leave the indented block, the math block ends
186+
if in_math_block and not (
187+
line.startswith(" ") or line.startswith("\t") or line.strip() == ""
188+
):
189+
in_math_block = False
190+
if equation_lines:
191+
wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$")
192+
equation_lines = []
193+
wrapped_lines.append(line)
194+
195+
return "\n".join(wrapped_lines)
196+
197+
198+
# https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#docstring-sections
199+
_non_example_docstring_section_headers = (
200+
"Args",
201+
"Arguments",
202+
"Attention",
203+
"Attributes",
204+
"Caution",
205+
"Danger",
206+
"Error",
207+
"Hint",
208+
"Important",
209+
"Keyword Args",
210+
"Keyword Arguments",
211+
"Methods",
212+
"Note",
213+
"Notes",
214+
"Other Parameters",
215+
"Parameters",
216+
"Return",
217+
"Returns",
218+
"Raise",
219+
"Raises",
220+
"References",
221+
"See Also",
222+
"Tip",
223+
"Todo",
224+
"Warning",
225+
"Warnings",
226+
"Warns",
227+
"Yield",
228+
"Yields",
229+
)
230+
231+
232+
_examples_start_pattern = re.compile(r".. (rubric|admonition):: Examples")
233+
_next_section_pattern = re.compile(
234+
"|".join(
235+
[
236+
rf".. (rubric|admonition)::\s*{header}"
237+
for header in _non_example_docstring_section_headers
238+
]
239+
# If examples section is last, processed by numpydoc may appear at end.
240+
+ [r"\!\! processed by numpydoc \!\!"]
241+
# Attributes section sometimes has no directive.
242+
+ [r":Attributes:"]
243+
)
244+
)
245+
246+
247+
def insert_try_examples_directive(lines, **options):
248+
"""Adds try_examples directive to Examples section of a docstring.
249+
250+
Hack to allow for a config option to enable try_examples functionality
251+
in all Examples sections (unless a comment "..! disable_try_examples" is
252+
added explicitly after the section header.)
253+
254+
255+
Parameters
256+
----------
257+
docstring : list of str
258+
Lines of a docstring at time of "autodoc-process-docstring", with section
259+
headers denoted by `.. rubric::` or `.. admonition::`.
260+
261+
262+
Returns
263+
-------
264+
list of str
265+
Updated version of the input docstring which has a try_examples directive
266+
inserted in the Examples section (if one exists) with all Examples content
267+
indented beneath it. Does nothing if the comment "..! disable_try_examples"
268+
is included at the top of the Examples section. Also a no-op if the
269+
try_examples directive is already included.
270+
"""
271+
# Search for start of an Examples section
272+
for left_index, line in enumerate(lines):
273+
if _examples_start_pattern.search(line):
274+
break
275+
else:
276+
# No Examples section found
277+
return lines[:]
278+
279+
# Jump to next line
280+
left_index += 1
281+
# Skip empty lines to get to the first content line
282+
while left_index < len(lines) and not lines[left_index].strip():
283+
left_index += 1
284+
if left_index == len(lines):
285+
# Examples section had no content, no need to insert directive.
286+
return lines[:]
287+
288+
# Check for the "..! disable_try_examples" comment.
289+
if lines[left_index].strip() == "..! disable_try_examples::":
290+
# If so, do not insert directive.
291+
return lines[:]
292+
293+
# Check if the ".. try_examples::" directive already exists
294+
if ".. try_examples::" == lines[left_index].strip():
295+
# If so, don't need to insert again.
296+
return lines[:]
297+
298+
# Find the end of the Examples section
299+
right_index = left_index
300+
while right_index < len(lines) and not _next_section_pattern.search(
301+
lines[right_index]
302+
):
303+
right_index += 1
304+
if "!! processed by numpydoc !!" in lines[right_index]:
305+
# Sometimes the .. appears on an earlier line than !! processed by numpydoc !!
306+
if not re.search(
307+
r"\.\.\s+\!\! processed by numpy doc \!\!", lines[right_index]
308+
):
309+
while lines[right_index].strip() != "..":
310+
right_index -= 1
311+
312+
# Add the ".. try_examples::" directive and indent the content of the Examples section
313+
new_lines = (
314+
lines[:left_index]
315+
+ [".. try_examples::"]
316+
+ [f" :{key}: {value}" for key, value in options.items()]
317+
+ [""]
318+
+ [" " + line for line in lines[left_index:right_index]]
319+
+ [""]
320+
+ lines[right_index:]
321+
)
322+
323+
return new_lines

jupyterlite_sphinx/jupyterlite_sphinx.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,18 @@
6767
transform: translateY(-50%) translateX(-50%) scale(1.2);
6868
}
6969
}
70+
71+
.try_examples_iframe_container {
72+
position: relative;
73+
cursor: pointer;
74+
}
75+
76+
77+
.try_examples_outer_container {
78+
position: relative;
79+
}
80+
81+
82+
.hidden {
83+
display: none;
84+
}

jupyterlite_sphinx/jupyterlite_sphinx.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,37 @@ window.jupyterliteConcatSearchParams = (iframeSrc, params) => {
3939
return iframeSrc;
4040
}
4141
}
42+
43+
44+
window.tryExamplesShowIframe = (
45+
examplesContainerId, iframeContainerId, iframeParentContainerId, iframeSrc
46+
) => {
47+
const examplesContainer = document.getElementById(examplesContainerId);
48+
const iframeParentContainer = document.getElementById(iframeParentContainerId);
49+
const iframeContainer = document.getElementById(iframeContainerId);
50+
51+
let iframe = iframeContainer.querySelector('iframe.jupyterlite_sphinx_raw_iframe');
52+
53+
if (!iframe) {
54+
const examples = examplesContainer.querySelector('.try_examples_content');
55+
iframe = document.createElement('iframe');
56+
iframe.src = iframeSrc;
57+
iframe.style.width = '100%';
58+
iframe.style.height = `${examples.offsetHeight}px`;
59+
iframe.classList.add('jupyterlite_sphinx_raw_iframe');
60+
examplesContainer.classList.add("hidden");
61+
iframeContainer.appendChild(iframe);
62+
} else {
63+
examplesContainer.classList.add("hidden");
64+
}
65+
iframeParentContainer.classList.remove("hidden");
66+
}
67+
68+
69+
window.tryExamplesHideIframe = (examplesContainerId, iframeParentContainerId) => {
70+
const examplesContainer = document.getElementById(examplesContainerId);
71+
const iframeParentContainer = document.getElementById(iframeParentContainerId);
72+
73+
iframeParentContainer.classList.add("hidden");
74+
examplesContainer.classList.remove("hidden");
75+
}

0 commit comments

Comments
 (0)