Skip to content

Commit a5e08dc

Browse files
authored
Merge pull request #497 from neuromatch/jupyter-book-2
Jupyter book 2
2 parents fa17adc + cc3a973 commit a5e08dc

3 files changed

Lines changed: 132 additions & 13 deletions

File tree

.github/workflows/publish-book-v2.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ jobs:
9393
env:
9494
BASE_URL: ${{ env.BASE_URL }}
9595

96+
- name: Strip error output divs from built HTML
97+
run: python parse_html_for_errors_v2.py student
98+
9699
- name: Copy CNAME for custom domain
97100
run: cp CNAME book/_build/html/CNAME
98101

generate_book_v2.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -235,28 +235,34 @@ def pre_process_notebook(file_path):
235235
content = open_in_colab_new_tab(content)
236236
content = change_video_widths(content)
237237
content = link_hidden_cells(content)
238-
content = tag_stub_cells(content)
238+
if ARG == "student":
239+
content = tag_cells_allow_errors(content)
239240
with open(file_path, "w", encoding="utf-8") as fh:
240241
json.dump(content, fh, indent=1, ensure_ascii=False)
241242

242243

243-
def tag_stub_cells(content):
244-
"""Add skip-execution tag to cells containing raise NotImplementedError.
244+
def tag_cells_allow_errors(content):
245+
"""Add raises-exception tag to every code cell.
245246
246-
JB1 used allow_errors:true to silently swallow errors from these stub cells.
247-
JB2 has no global equivalent, so we skip execution instead — the source is
248-
still rendered (students see the stub), but no error traceback is produced.
247+
JB1 used allow_errors:true globally so execution continued past any error
248+
(NotImplementedError stubs, downstream NameErrors, etc.) and error output
249+
divs were stripped from the HTML by parse_html_for_errors.py.
250+
251+
JB2 has no global allow_errors equivalent, but raises-exception on a cell
252+
tells MyST to continue executing subsequent cells after an error. We apply
253+
it to all code cells so that the behaviour matches JB1 exactly. A companion
254+
post-processing script (parse_html_for_errors_v2.py) then strips the error
255+
output divs from the built HTML before deployment.
249256
"""
250257
for cell in content["cells"]:
251258
if cell["cell_type"] != "code":
252259
continue
253-
if any("NotImplementedError" in s for s in cell.get("source", [])):
254-
if "metadata" not in cell:
255-
cell["metadata"] = {}
256-
if "tags" not in cell["metadata"]:
257-
cell["metadata"]["tags"] = []
258-
if "skip-execution" not in cell["metadata"]["tags"]:
259-
cell["metadata"]["tags"].append("skip-execution")
260+
if "metadata" not in cell:
261+
cell["metadata"] = {}
262+
if "tags" not in cell["metadata"]:
263+
cell["metadata"]["tags"] = []
264+
if "raises-exception" not in cell["metadata"]["tags"]:
265+
cell["metadata"]["tags"].append("raises-exception")
260266
return content
261267

262268

parse_html_for_errors_v2.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Post-process JB2-built HTML to strip error output divs.
4+
5+
JB1 equivalent: nmaci/scripts/parse_html_for_errors.py
6+
JB2 difference: MyST uses different CSS classes for cell output containers.
7+
8+
JB1 class: "cell_output docutils container"
9+
JB2 classes tried (in order):
10+
- "cell_output" (MyST book-theme)
11+
- "output" (fallback)
12+
- any <div> containing the error text (last resort)
13+
14+
Run as: python parse_html_for_errors_v2.py student
15+
"""
16+
17+
import os
18+
import sys
19+
import yaml
20+
from bs4 import BeautifulSoup
21+
22+
ARG = sys.argv[1] # "student" or "instructor"
23+
24+
ERROR_STRINGS = ["NotImplementedError", "NameError"]
25+
26+
27+
def main():
28+
with open("tutorials/materials.yml") as fh:
29+
materials = yaml.load(fh, Loader=yaml.FullLoader)
30+
31+
html_directory = "book/_build/html/"
32+
total_removed = 0
33+
34+
for m in materials:
35+
name = f"{m['day']}_{''.join(m['name'].split())}"
36+
37+
notebook_paths = []
38+
if os.path.exists(f"tutorials/{name}/{m['day']}_Intro.ipynb"):
39+
notebook_paths.append(
40+
f"{html_directory}/tutorials/{name}/{ARG}/{m['day']}_Intro.html"
41+
)
42+
notebook_paths += [
43+
f"{html_directory}/tutorials/{name}/{ARG}/{m['day']}_Tutorial{i + 1}.html"
44+
for i in range(m["tutorials"])
45+
]
46+
if os.path.exists(f"tutorials/{name}/{m['day']}_Outro.ipynb"):
47+
notebook_paths.append(
48+
f"{html_directory}/tutorials/{name}/{ARG}/{m['day']}_Outro.html"
49+
)
50+
51+
for html_path in notebook_paths:
52+
if not os.path.exists(html_path):
53+
print(f" Warning: {html_path} not found, skipping")
54+
continue
55+
56+
with open(html_path, encoding="utf-8") as f:
57+
contents = f.read()
58+
59+
parsed_html = BeautifulSoup(contents, features="html.parser")
60+
removed = strip_error_divs(parsed_html)
61+
total_removed += removed
62+
63+
# Put solution figures in center (matches JB1 behaviour)
64+
for img in parsed_html.find_all("img", alt=True):
65+
if img["alt"] == "Solution hint":
66+
img["align"] = "center"
67+
img["class"] = "align-center"
68+
69+
with open(html_path, "w", encoding="utf-8") as f:
70+
f.write(str(parsed_html))
71+
72+
if removed:
73+
print(
74+
f" Stripped {removed} error div(s) from {os.path.basename(html_path)}"
75+
)
76+
77+
print(f"Done. Removed {total_removed} error output div(s) total.")
78+
79+
80+
def strip_error_divs(parsed_html):
81+
"""Remove output divs that contain NotImplementedError or NameError text.
82+
83+
Tries JB1's class first, then JB2/MyST class names, then a broad sweep.
84+
Returns the number of divs removed.
85+
"""
86+
removed = 0
87+
88+
# JB1 class (sphinx/docutils)
89+
candidates = parsed_html.find_all(
90+
"div", {"class": "cell_output docutils container"}
91+
)
92+
93+
# JB2/MyST book-theme output wrapper
94+
if not candidates:
95+
candidates = parsed_html.find_all("div", {"class": "cell_output"})
96+
97+
# Broader fallback: any <div> that directly wraps an error traceback
98+
if not candidates:
99+
candidates = parsed_html.find_all("div", class_=lambda c: c and "output" in c)
100+
101+
for div in candidates:
102+
if any(err in str(div) for err in ERROR_STRINGS):
103+
div.decompose()
104+
removed += 1
105+
106+
return removed
107+
108+
109+
if __name__ == "__main__":
110+
main()

0 commit comments

Comments
 (0)