Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add try_examples directive for adding interactivity to sphinx Examples sections #111

Merged
merged 26 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bfe83d4
Add try_examples directive
steppi Sep 29, 2023
2653af2
Fix associated css for try_examples extension
steppi Oct 5, 2023
075f4cf
Make global_enable_try_examples work
steppi Oct 10, 2023
92d709f
Make LaTeX processing work correctly
steppi Oct 11, 2023
119004a
Fix rendering of Examples
steppi Oct 11, 2023
88de47d
Add support for multiline blocks in Examples
steppi Oct 12, 2023
f1ea911
Make toggle examples <-> notebook work
steppi Oct 13, 2023
f141f3c
Allow configuration of button css
steppi Oct 13, 2023
0c43e89
Fix cases where no directive is inserted
steppi Oct 16, 2023
0ddcc17
Final tweaks
steppi Oct 17, 2023
e983eb3
Get correct relative path to doc root
steppi Oct 18, 2023
9407f33
Allow leading whitespace in latex expression :math:
steppi Oct 18, 2023
3e65eb3
Handle edgecase, Examples is last section
steppi Oct 18, 2023
8279fdc
Strip out content which should be ignored in notebook
steppi Oct 18, 2023
0e360e6
Handle :Attributes: edge case for section header
steppi Oct 18, 2023
aae590a
Allow whitespace in processed by numpydoc part
steppi Oct 18, 2023
64fcdc0
Handle edgecase for processed by numpydoc
steppi Oct 19, 2023
4484b2f
Handle case with multiple output lines
steppi Oct 19, 2023
f98d644
Fix incorrectly formatted arrays in output
steppi Oct 19, 2023
daf379f
Handle references in examples section
steppi Oct 19, 2023
e20c7f2
Reword some comments
steppi Oct 19, 2023
7a659c3
Fix bugs in latex processing
steppi Oct 20, 2023
6b9ec71
Add global to global configuration var names
steppi Oct 20, 2023
e50e071
Add Python language to notebook metadata
steppi Oct 26, 2023
c1e2dcc
Format code with black
steppi Oct 26, 2023
b043a6f
Update jupyterlite_sphinx/jupyterlite_sphinx.js
steppi Nov 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 323 additions & 0 deletions jupyterlite_sphinx/_try_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
import nbformat as nbf
from nbformat.v4 import new_code_cell, new_markdown_cell
import re


def examples_to_notebook(input_lines):
"""Parse examples section of a docstring and convert to Jupyter notebook.

Parameters
----------
input_lines : iterable of str.
Lines within

Returns
-------
dict
json for a Jupyter Notebook

Examples
--------
>>> from jupyterlite_sphinx.generate_notebook import examples_to_notebook

>>> input_lines = [
>>> "Add two numbers. This block of text will appear as a\n",
>>> "markdown cell. The following block will become a code\n",
>>> "cell with the value 4 contained in the output.",
>>> "\n",
>>> ">>> x = 2\n",
>>> ">>> y = 2\n",
>>> ">>> x + y\n",
>>> "4\n",
>>> "\n",
>>> "Inline LaTeX like :math:`x + y = 4` will be converted\n",
>>> "correctly within markdown cells. As will block LaTeX\n",
>>> "such as\n",
>>> "\n",
>>> ".. math::\n",
>>> "\n",
>>> " x = 2,\;y = 2
>>> "\n",
>>> " x + y = 4\n",
>>> ]
>>> notebook = examples_to_notebook(input_lines)
"""
nb = nbf.v4.new_notebook()

code_lines = []
md_lines = []
output_lines = []
inside_multiline_code_block = False

ignore_directives = [".. plot::", ".. only::"]
inside_ignore_directive = False

for line in input_lines:
line = line.rstrip("\n")

# Content underneath some directives should be ignored when generating notebook.
if any(line.startswith(directive) for directive in ignore_directives):
inside_ignore_directive = True
continue
if inside_ignore_directive:
if line == "" or line[0].isspace():
continue
else:
inside_ignore_directive = False

if line.startswith(">>>"): # This is a code line.
if output_lines:
# If there are pending output lines, we must be starting a new
# code block.
_append_code_cell_and_clear_lines(code_lines, output_lines, nb)
if inside_multiline_code_block:
# A multiline codeblock is ending.
inside_multiline_code_block = False
# If there is any pending markdown text, add it to the notebook
if md_lines:
_append_markdown_cell_and_clear_lines(md_lines, nb)

# Add line of code, removing '>>> ' prefix
code_lines.append(line[4:])
elif line.startswith("...") and code_lines:
# This is a line of code in a multiline code block.
inside_multiline_code_block = True
code_lines.append(line[4:])
elif line.rstrip("\n") == "" and code_lines:
# A blank line means a code block has ended.
_append_code_cell_and_clear_lines(code_lines, output_lines, nb)
elif code_lines:
# Non-blank non ">>>" prefixed line must be output of previous code block.
output_lines.append(line)
else:
# Anything else should be treated as markdown.
md_lines.append(line)

# After processing all lines, add pending markdown or code to the notebook if
# any exists.
if md_lines:
_append_markdown_cell_and_clear_lines(md_lines, nb)
if code_lines:
_append_code_cell_and_clear_lines(code_lines, output_lines, nb)

nb["metadata"] = {
"kernelspec": {
"display_name": "Python",
"language": "python",
"name": "python",
},
"language_info": {
"name": "python",
},
}
return nb


def _append_code_cell_and_clear_lines(code_lines, output_lines, notebook):
"""Append new code cell to notebook, clearing lines."""
code_text = "\n".join(code_lines)
cell = new_code_cell(code_text)
if output_lines:
combined_output = "\n".join(output_lines)
cell.outputs.append(
nbf.v4.new_output(
output_type="execute_result",
data={"text/plain": combined_output},
),
)
notebook.cells.append(cell)
output_lines.clear()
code_lines.clear()


def _append_markdown_cell_and_clear_lines(markdown_lines, notebook):
"""Append new markdown cell to notebook, clearing lines."""
markdown_text = "\n".join(markdown_lines)
# Convert blocks of LaTeX equations
markdown_text = _process_latex(markdown_text)
markdown_text = _strip_ref_identifiers(markdown_text)
notebook.cells.append(new_markdown_cell(markdown_text))
markdown_lines.clear()


_ref_identifier_pattern = re.compile(r"\[R[a-f0-9]+-(?P<ref_num>\d+)\]_")


def _strip_ref_identifiers(md_text):
"""Remove identifiers from references in notebook.

Each docstring gets a unique identifier in order to have unique internal
links for each docstring on a page.

They look like [R4c2dbc17006a-1]_. We strip these out so they don't appear
in the notebooks. The above would be replaced with [1]_.
"""
return _ref_identifier_pattern.sub(r"[\g<ref_num>]", md_text)


def _process_latex(md_text):
# Map rst latex directive to $ so latex renders in notebook.
md_text = re.sub(
r":math:\s*`(?P<latex>.*?)`", r"$\g<latex>$", md_text, flags=re.DOTALL
)

lines = md_text.split("\n")
in_math_block = False
wrapped_lines = []
equation_lines = []

for line in lines:
if line.strip() == ".. math::":
in_math_block = True
continue # Skip the '.. math::' line

if in_math_block:
if line.strip() == "":
if equation_lines:
# Join and wrap the equations, then reset
wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$")
equation_lines = []
elif line.startswith(" ") or line.startswith("\t"):
equation_lines.append(line.strip())
else:
wrapped_lines.append(line)

# If you leave the indented block, the math block ends
if in_math_block and not (
line.startswith(" ") or line.startswith("\t") or line.strip() == ""
):
in_math_block = False
if equation_lines:
wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$")
equation_lines = []
wrapped_lines.append(line)

return "\n".join(wrapped_lines)


# https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#docstring-sections
_non_example_docstring_section_headers = (
"Args",
"Arguments",
"Attention",
"Attributes",
"Caution",
"Danger",
"Error",
"Hint",
"Important",
"Keyword Args",
"Keyword Arguments",
"Methods",
"Note",
"Notes",
"Other Parameters",
"Parameters",
"Return",
"Returns",
"Raise",
"Raises",
"References",
"See Also",
"Tip",
"Todo",
"Warning",
"Warnings",
"Warns",
"Yield",
"Yields",
)


_examples_start_pattern = re.compile(r".. (rubric|admonition):: Examples")
_next_section_pattern = re.compile(
"|".join(
[
rf".. (rubric|admonition)::\s*{header}"
for header in _non_example_docstring_section_headers
]
# If examples section is last, processed by numpydoc may appear at end.
+ [r"\!\! processed by numpydoc \!\!"]
# Attributes section sometimes has no directive.
+ [r":Attributes:"]
)
)


def insert_try_examples_directive(lines, **options):
"""Adds try_examples directive to Examples section of a docstring.

Hack to allow for a config option to enable try_examples functionality
in all Examples sections (unless a comment "..! disable_try_examples" is
added explicitly after the section header.)


Parameters
----------
docstring : list of str
Lines of a docstring at time of "autodoc-process-docstring", with section
headers denoted by `.. rubric::` or `.. admonition::`.


Returns
-------
list of str
Updated version of the input docstring which has a try_examples directive
inserted in the Examples section (if one exists) with all Examples content
indented beneath it. Does nothing if the comment "..! disable_try_examples"
is included at the top of the Examples section. Also a no-op if the
try_examples directive is already included.
"""
# Search for start of an Examples section
for left_index, line in enumerate(lines):
if _examples_start_pattern.search(line):
break
else:
# No Examples section found
return lines[:]

# Jump to next line
left_index += 1
# Skip empty lines to get to the first content line
while left_index < len(lines) and not lines[left_index].strip():
left_index += 1
if left_index == len(lines):
# Examples section had no content, no need to insert directive.
return lines[:]

# Check for the "..! disable_try_examples" comment.
if lines[left_index].strip() == "..! disable_try_examples::":
# If so, do not insert directive.
return lines[:]

# Check if the ".. try_examples::" directive already exists
if ".. try_examples::" == lines[left_index].strip():
# If so, don't need to insert again.
return lines[:]

# Find the end of the Examples section
right_index = left_index
while right_index < len(lines) and not _next_section_pattern.search(
lines[right_index]
):
right_index += 1
if "!! processed by numpydoc !!" in lines[right_index]:
# Sometimes the .. appears on an earlier line than !! processed by numpydoc !!
if not re.search(
r"\.\.\s+\!\! processed by numpy doc \!\!", lines[right_index]
):
while lines[right_index].strip() != "..":
right_index -= 1

# Add the ".. try_examples::" directive and indent the content of the Examples section
new_lines = (
lines[:left_index]
+ [".. try_examples::"]
+ [f" :{key}: {value}" for key, value in options.items()]
+ [""]
+ [" " + line for line in lines[left_index:right_index]]
+ [""]
+ lines[right_index:]
)

return new_lines
15 changes: 15 additions & 0 deletions jupyterlite_sphinx/jupyterlite_sphinx.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,18 @@
transform: translateY(-50%) translateX(-50%) scale(1.2);
}
}

.try_examples_iframe_container {
position: relative;
cursor: pointer;
}


.try_examples_outer_container {
position: relative;
}


.hidden {
display: none;
}
35 changes: 35 additions & 0 deletions jupyterlite_sphinx/jupyterlite_sphinx.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,38 @@ window.jupyterliteConcatSearchParams = (iframeSrc, params) => {
return iframeSrc;
}
}


window.tryExamplesShowIframe = (
examplesContainerId, iframeContainerId, iframeParentContainerId, iframeSrc
) => {
const examplesContainer = document.getElementById(examplesContainerId);
const iframeParentContainer = document.getElementById(iframeParentContainerId);
const iframeContainer = document.getElementById(iframeContainerId);

let iframe = iframeContainer.querySelector('iframe.jupyterlite_sphinx_raw_iframe');

if (!iframe) {
const examples = examplesContainer.querySelector('.try_examples_content');
iframe = document.createElement('iframe');
iframe.src = iframeSrc;
iframe.style.width = '100%';
iframe.style.height = `${examples.offsetHeight}px`;
iframe.classList.add('jupyterlite_sphinx_raw_iframe');
examplesContainer.classList.add("hidden");
iframeContainer.appendChild(iframe);
}
else {
examplesContainer.classList.add("hidden");
}
iframeParentContainer.classList.remove("hidden");
steppi marked this conversation as resolved.
Show resolved Hide resolved
}


window.tryExamplesHideIframe = (examplesContainerId, iframeParentContainerId) => {
const examplesContainer = document.getElementById(examplesContainerId);
const iframeParentContainer = document.getElementById(iframeParentContainerId);

iframeParentContainer.classList.add("hidden");
examplesContainer.classList.remove("hidden");
}
Loading
Loading