Skip to content

Commit 8061c30

Browse files
Support the usage of Markdown-based notebooks with "Lite" directives (#221)
* Add Jupytext as a dependency * Add ability to convert MyST-MD notebooks on-the-fly * Document ability to use MyST notebooks * Add a sample MyST notebook * Showcase `.md` example for NotebookLite, Voici * Add `jupytext` for RTD builds * Drop UUID suffix, cache based on timestamps * Clarify MyST notebook in notebook contents * Handle notebook collision * Refactor stripping logic + strip MyST notebooks * Add docs on how to strip from Markdown notebooks * Avoid conflict: rename Markdown notebook * Handle collisions among included files in docs build
1 parent d286a80 commit 8061c30

File tree

8 files changed

+229
-32
lines changed

8 files changed

+229
-32
lines changed

dev-environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ dependencies:
66
- jupyter_server
77
- jupyterlab_server
88
- jupyterlite-core >=0.3,<0.4
9+
- jupytext
910
- pydata-sphinx-theme
1011
- micromamba
1112
- myst-parser

docs/configuration.md

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ This behaviour can be enabled by setting the following config:
9696
strip_tagged_cells = True
9797
```
9898

99-
and then by tagging the cells you want to strip with the tag `jupyterlite_sphinx_strip` in the JSON metadata
99+
and then by tagging the cells you want to strip with the `jupyterlite_sphinx_strip` tag in the JSON metadata
100100
of the cell, like this:
101101

102102
```json
@@ -109,7 +109,7 @@ of the cell, like this:
109109

110110
This is useful when you want to remove some cells from the rendered notebook in the JupyterLite
111111
console, for example, cells that are used for adding reST-based directives or other
112-
Sphinx-specific content.
112+
Sphinx-specific content. It can be used to remove either code cells or Markdown cells.
113113

114114
For example, you can use this feature to remove the `toctree` directive from the rendered notebook
115115
in the JupyterLite console:
@@ -147,9 +147,43 @@ in the JupyterLite console:
147147
where the cell with the `toctree` directive will be removed from the rendered notebook in
148148
the JupyterLite console.
149149

150+
In the case of a MyST notebook, you can use the following syntax to tag the cells:
151+
152+
````markdown
153+
154+
+++ {"tags": ["jupyterlite_sphinx_strip"]}
155+
156+
# Heading 1
157+
158+
This is a Markdown cell that will be stripped from the rendered notebook in the
159+
JupyterLite console.
160+
161+
+++
162+
163+
```{code-cell} ipython3
164+
:tags: [jupyterlite_sphinx_strip]
165+
166+
# This is a code cell that will be stripped from the rendered notebook in the
167+
# JupyterLite console.
168+
def foo():
169+
print(3)
170+
```
171+
172+
```{code-cell} ipython3
173+
# This cell will not be stripped
174+
def bar():
175+
print(4)
176+
```
177+
````
178+
179+
The Markdown cells are not wrapped, and hence the `+++` and `+++` markers are used to
180+
indicate where the cells start and end. For more details around writing and customising
181+
MyST-flavoured notebooks, please refer to the
182+
[MyST Markdown overview](https://jupyterbook.org/en/stable/content/myst.html).
183+
150184
Note that this feature is only available for the `NotebookLite`, `JupyterLite`, and the
151-
`Voici` directives and works with the `.ipynb` files passed to them. It is not implemented
152-
for the `TryExamples` directive.
185+
`Voici` directives and works with the `.md` (MyST) or `.ipynb` files passed to them. It
186+
is not implemented for the `TryExamples` directive.
153187

154188
## Disable the `.ipynb` docs source binding
155189

docs/directives/jupyterlite.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ You can also pass a Notebook file to open automatically:
3636
:prompt_color: #00aa42
3737
```
3838

39+
The notebook can also be a MyST-flavoured Markdown file that will be converted to a Jupyter Notebook before being opened.
40+
41+
```rst
42+
.. jupyterlite:: my_markdown_notebook.md
43+
:width: 100%
44+
:height: 600px
45+
:prompt: Try JupyterLite!
46+
:prompt_color: #00aa42
47+
```
48+
49+
```{eval-rst}
50+
.. jupyterlite:: my_markdown_notebook.md
51+
:width: 100%
52+
:height: 600px
53+
:prompt: Try JupyterLite!
54+
:prompt_color: #00aa42
55+
```
56+
3957
If you use the `:new_tab:` option in the directive, the Notebook will be opened in a new browser tab.
4058
The tab will render the full-fledged Lab interface, which is more complete and showcases all features
4159
of JupyterLite.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
jupytext:
3+
text_representation:
4+
extension: .md
5+
format_name: myst
6+
format_version: 0.13
7+
jupytext_version: 1.16.4
8+
kernelspec:
9+
display_name: Python 3 (ipykernel)
10+
language: python
11+
name: python3
12+
---
13+
14+
# This is a MyST Markdown-flavoured notebook
15+
16+
```{code-cell} ipython3
17+
def foo():
18+
print(3)
19+
```
20+
21+
```{code-cell} ipython3
22+
foo()
23+
```

docs/directives/notebooklite.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
:prompt: Try classic Notebook!
1717
```
1818

19-
You can also pass a Notebook file to open:
19+
You can provide a notebook (either Jupyter-based or MyST-Markdown flavoured) to open:
20+
21+
1. Jupyter Notebook
2022

2123
```rst
2224
.. notebooklite:: my_notebook.ipynb
@@ -32,6 +34,22 @@ You can also pass a Notebook file to open:
3234
:prompt: Try classic Notebook!
3335
```
3436

37+
2. MyST Markdown
38+
39+
```rst
40+
.. notebooklite:: my_markdown_notebook.md
41+
:width: 100%
42+
:height: 600px
43+
:prompt: Try classic Notebook!
44+
```
45+
46+
```{eval-rst}
47+
.. notebooklite:: my_markdown_notebook.md
48+
:width: 100%
49+
:height: 600px
50+
:prompt: Try classic Notebook!
51+
```
52+
3553
If you use the `:new_tab:` option in the directive, the Notebook will be opened in a new browser tab.
3654
The tab will render the classic Notebook UI, which is more minimal and does not showcase the entire
3755
Lab interface.

docs/directives/voici.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
:height: 600px
1313
```
1414

15-
You can provide a notebook that will be rendered with Voici:
15+
You can provide a notebook file (either Jupyter-based or MyST-Markdown flavoured) that will be
16+
rendered with Voici:
17+
18+
1. Jupyter Notebook
1619

1720
```rst
1821
.. voici:: my_notebook.ipynb
@@ -28,6 +31,22 @@ You can provide a notebook that will be rendered with Voici:
2831
:prompt_color: #dc3545
2932
```
3033

34+
2. MyST Markdown
35+
36+
```rst
37+
.. voici:: my_markdown_notebook.md
38+
:height: 600px
39+
:prompt: Try Voici!
40+
:prompt_color: `#dc3545`
41+
```
42+
43+
```{eval-rst}
44+
.. voici:: my_markdown_notebook.md
45+
:height: 600px
46+
:prompt: Try Voici!
47+
:prompt_color: `#dc3545`
48+
```
49+
3150
If you use the `:new_tab:` option in the directive, the Voici dashboard will execute and render
3251
the notebook in a new browser tab, instead of in the current page.
3352

jupyterlite_sphinx/jupyterlite_sphinx.py

Lines changed: 109 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from ._try_examples import examples_to_notebook, insert_try_examples_directive
2626

27+
import jupytext
2728
import nbformat
2829

2930
try:
@@ -478,6 +479,72 @@ class _LiteDirective(SphinxDirective):
478479
"new_tab_button_text": directives.unchanged,
479480
}
480481

482+
def _target_is_stale(self, source_path: Path, target_path: Path) -> bool:
483+
# Used as a heuristic to determine if a markdown notebook needs to be
484+
# converted or reconverted to ipynb.
485+
if not target_path.exists():
486+
return True
487+
488+
return source_path.stat().st_mtime > target_path.stat().st_mtime
489+
490+
# TODO: Jupytext support many more formats for conversion, but we only
491+
# consider Markdown and IPyNB for now. If we add more formats someday,
492+
# we should also consider them here.
493+
def _assert_no_conflicting_nb_names(
494+
self, source_path: Path, notebooks_dir: Path
495+
) -> None:
496+
"""Check for duplicate notebook names in the documentation sources.
497+
Raises if any notebooks would conflict when converted to IPyNB."""
498+
target_stem = source_path.stem
499+
target_ipynb = f"{target_stem}.ipynb"
500+
501+
# Only look for conflicts in source directories and among referenced notebooks.
502+
# We do this to prevent conflicts with other files, say, in the "_contents/"
503+
# directory as a result of a previous failed/interrupted build.
504+
if source_path.parent != notebooks_dir:
505+
506+
# We only consider conflicts if notebooks are actually referenced in
507+
# a directive, to prevent false posiitves from being raised.
508+
if hasattr(self.env, "jupyterlite_notebooks"):
509+
for existing_nb in self.env.jupyterlite_notebooks:
510+
existing_path = Path(existing_nb)
511+
if (
512+
existing_path.stem == target_stem
513+
and existing_path != source_path
514+
):
515+
516+
raise RuntimeError(
517+
"All notebooks marked for inclusion with JupyterLite must have a "
518+
f"unique file basename. Found conflict between {source_path} and {existing_path}."
519+
)
520+
521+
return target_ipynb
522+
523+
def _strip_notebook_cells(
524+
self, nb: nbformat.NotebookNode
525+
) -> List[nbformat.NotebookNode]:
526+
"""Strip cells based on the presence of the "jupyterlite_sphinx_strip" tag
527+
in the metadata. The content meant to be stripped must be inside its own cell
528+
cell so that the cell itself gets removed from the notebooks. This is so that
529+
we don't end up removing useful data or directives that are not meant to be
530+
removed.
531+
532+
Parameters
533+
----------
534+
nb : nbformat.NotebookNode
535+
The notebook object to be stripped.
536+
537+
Returns
538+
-------
539+
List[nbformat.NotebookNode]
540+
A list of cells that are not meant to be stripped.
541+
"""
542+
return [
543+
cell
544+
for cell in nb.cells
545+
if "jupyterlite_sphinx_strip" not in cell.metadata.get("tags", [])
546+
]
547+
481548
def run(self):
482549
width = self.options.pop("width", "100%")
483550
height = self.options.pop("height", "1000px")
@@ -498,43 +565,59 @@ def run(self):
498565
)
499566

500567
if self.arguments:
568+
# Keep track of the notebooks we are going through, so that we don't
569+
# operate on notebooks that are not meant to be included in the built
570+
# docs, i.e., those that have not been referenced in the docs via our
571+
# directives anywhere.
572+
if not hasattr(self.env, "jupyterlite_notebooks"):
573+
self.env.jupyterlite_notebooks = set()
574+
501575
# As with other directives like literalinclude, an absolute path is
502576
# assumed to be relative to the document root, and a relative path
503577
# is assumed to be relative to the source file
504578
rel_filename, notebook = self.env.relfn2path(self.arguments[0])
505579
self.env.note_dependency(rel_filename)
506580

507-
notebook_name = os.path.basename(notebook)
581+
notebook_path = Path(notebook)
582+
583+
self.env.jupyterlite_notebooks.add(str(notebook_path))
508584

509-
notebooks_dir = Path(self.env.app.srcdir) / CONTENT_DIR / notebook_name
585+
notebooks_dir = Path(self.env.app.srcdir) / CONTENT_DIR
586+
os.makedirs(notebooks_dir, exist_ok=True)
587+
588+
self._assert_no_conflicting_nb_names(notebook_path, notebooks_dir)
589+
target_name = f"{notebook_path.stem}.ipynb"
590+
target_path = notebooks_dir / target_name
510591

511592
notebook_is_stripped: bool = self.env.config.strip_tagged_cells
512593

513-
# Create a folder to copy the notebooks to and for NotebookLite to find
514-
os.makedirs(os.path.dirname(notebooks_dir), exist_ok=True)
515-
516-
if notebook_is_stripped:
517-
# Note: the directives meant to be stripped must be inside their own
518-
# cell so that the cell itself gets removed from the notebook. This
519-
# is so that we don't end up removing useful data or directives that
520-
# are not meant to be removed.
521-
522-
nb = nbformat.read(notebook, as_version=4)
523-
nb.cells = [
524-
cell
525-
for cell in nb.cells
526-
if "jupyterlite_sphinx_strip" not in cell.metadata.get("tags", [])
527-
]
528-
nbformat.write(nb, notebooks_dir, version=4)
529-
530-
# If notebook_is_stripped is False, then copy the notebook(s) to notebooks_dir.
531-
# If it is True, then they have already been copied to notebooks_dir by the
532-
# nbformat.write() function above.
594+
if notebook_path.suffix.lower() == ".md":
595+
if self._target_is_stale(notebook_path, target_path):
596+
nb = jupytext.read(str(notebook_path))
597+
if notebook_is_stripped:
598+
nb.cells = self._strip_notebook_cells(nb)
599+
with open(target_path, "w", encoding="utf-8") as f:
600+
nbformat.write(nb, f, version=4)
601+
602+
notebook = str(target_path)
603+
notebook_name = target_name
533604
else:
534-
try:
535-
shutil.copy(notebook, notebooks_dir)
536-
except shutil.SameFileError:
537-
pass
605+
notebook_name = notebook_path.name
606+
target_path = notebooks_dir / notebook_name
607+
608+
if notebook_is_stripped:
609+
nb = nbformat.read(notebook, as_version=4)
610+
nb.cells = self._strip_notebook_cells(nb)
611+
nbformat.write(nb, target_path, version=4)
612+
# If notebook_is_stripped is False, then copy the notebook(s) to notebooks_dir.
613+
# If it is True, then they have already been copied to notebooks_dir by the
614+
# nbformat.write() function above.
615+
else:
616+
try:
617+
shutil.copy(notebook, target_path)
618+
except shutil.SameFileError:
619+
pass
620+
538621
else:
539622
notebook_name = None
540623

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ docs = [
3030
"myst_parser",
3131
"pydata-sphinx-theme",
3232
"jupyterlite-xeus>=0.1.8,<0.3.0",
33+
"jupytext",
3334
]
3435

3536
[tool.hatch.version]

0 commit comments

Comments
 (0)