33In-process notebook renderer for m1-gpu-cpp demos.
44
55Executes jupytext .py notebooks by running cells with exec() in the same
6- Python process, avoiding Jupyter kernel subprocess issues on macOS CI where
7- Metal GPU access is available to the main process but not reliably inherited
8- by kernel subprocesses.
6+ Python process. This avoids the Jupyter kernel subprocess which cannot
7+ reliably access the Metal GPU on macOS CI runners.
98
10- Matplotlib figures are captured via a patched plt.show() and appended to
11- cell outputs as base64-encoded PNG images. nbconvert's HTMLExporter is then
12- called as a library (no subprocess) to produce the final HTML.
9+ Matplotlib figures are captured via a patched plt.show(). plt is obtained
10+ lazily from the exec namespace after the notebook's own import cell runs, so
11+ this script never imports matplotlib itself — the MPLBACKEND=Agg environment
12+ variable (set in CI) is sufficient to control the backend.
13+
14+ HTML rendering is done by spawning a separate nbconvert subprocess *after* all
15+ GPU work is finished, keeping nbconvert's heavy imports isolated from Metal.
1316
1417Usage:
15- python scripts/render_notebooks.py <output_dir> <notebook .py> [...]
18+ MPLBACKEND=Agg python scripts/render_notebooks.py <output_dir> <nb .py> [...]
1619"""
1720
1821import base64
1922import io
23+ import subprocess
2024import sys
2125import traceback
2226from pathlib import Path
2327
24- import matplotlib
25-
26- matplotlib .use ("Agg" )
27- import matplotlib .pyplot as plt
2828import jupytext
2929import nbformat
3030from nbformat import v4
31- from nbconvert .exporters import HTMLExporter
3231
3332
3433# ---------------------------------------------------------------------------
35- # Figure capture helpers
34+ # Figure capture helpers (plt accessed through exec namespace, never imported
35+ # at module level to avoid a matplotlib ↔ Metal conflict at initialisation)
3636# ---------------------------------------------------------------------------
3737
3838
39- def _capture_and_close () -> list [str ]:
40- """Save every open matplotlib figure as a base64 PNG and close all."""
39+ def _capture_and_close (plt ) -> list [str ]:
40+ """Save every open matplotlib figure as base64 PNG and close all."""
4141 images : list [str ] = []
4242 for fnum in plt .get_fignums ():
4343 buf = io .BytesIO ()
@@ -53,31 +53,30 @@ def _capture_and_close() -> list[str]:
5353# ---------------------------------------------------------------------------
5454
5555
56- def execute_notebook (py_path : Path , out_dir : Path ) -> None :
56+ def execute_notebook (py_path : Path , out_dir : Path ) -> Path :
57+ """Execute a jupytext .py notebook in-process and save the result as .ipynb."""
5758 print (f"Executing { py_path } ..." )
58- # Explicit fmt avoids jupytext leaking YAML front-matter into the first cell.
5959 nb = jupytext .read (str (py_path ), fmt = "py:light" )
6060
61- # Shared namespace — persists across cells, just like a real kernel session .
61+ # Shared namespace — persists across all cells, just like a real kernel.
6262 ns : dict = {"__name__" : "__main__" }
6363
64- # Patch plt.show() so that cells which call it trigger figure capture
65- # instead of trying to open a GUI window. Images are accumulated per-cell.
64+ # plt is obtained from ns after the notebook's first import cell runs.
65+ # cell_images accumulates captured figures for the current cell.
66+ plt_ref : list = [] # one-element list so the closure can rebind it
6667 cell_images : list [str ] = []
6768
6869 def _patched_show (* _args , ** _kwargs ) -> None :
69- cell_images .extend (_capture_and_close ())
70-
71- plt .show = _patched_show
70+ if plt_ref :
71+ cell_images .extend (_capture_and_close (plt_ref [0 ]))
7272
7373 for exec_count , cell in enumerate (nb .cells , start = 1 ):
7474 if cell .cell_type != "code" :
7575 continue
7676
77- # Drop IPython magic lines. jupytext stores them as `# %magic` comments
78- # in the .py source; when it converts to a notebook it strips the `# `
79- # prefix, so the cell source contains bare `%magic` lines which are not
80- # valid Python. Filter both forms.
77+ # Strip IPython magic lines. jupytext stores them as `# %magic`
78+ # comments; when converting to a notebook it removes the `# ` prefix,
79+ # producing bare `%magic` lines that are not valid Python.
8180 src_lines = [
8281 line
8382 for line in cell .source .splitlines ()
@@ -109,14 +108,21 @@ def _patched_show(*_args, **_kwargs) -> None:
109108 finally :
110109 sys .stdout = sys .__stdout__
111110
111+ # Once plt appears in the namespace, patch show() so subsequent cells
112+ # that call plt.show() trigger figure capture instead of a GUI window.
113+ if not plt_ref and "plt" in ns :
114+ plt_ref .append (ns ["plt" ])
115+ plt_ref [0 ].show = _patched_show
116+
112117 text = stdout_buf .getvalue ()
113118 if text :
114119 cell .outputs .append (
115120 v4 .new_output (output_type = "stream" , name = "stdout" , text = text )
116121 )
117122
118- # Capture any figures not yet collected by patched show().
119- cell_images .extend (_capture_and_close ())
123+ # Capture any figures not yet collected by the patched show().
124+ if plt_ref :
125+ cell_images .extend (_capture_and_close (plt_ref [0 ]))
120126
121127 for img_b64 in cell_images :
122128 cell .outputs .append (
@@ -127,15 +133,13 @@ def _patched_show(*_args, **_kwargs) -> None:
127133 )
128134 )
129135
130- # Render to HTML using nbconvert as a library (no subprocess).
131- exporter = HTMLExporter ()
132- exporter .exclude_input_prompt = True
133- body , _ = exporter .from_notebook_node (nb )
134-
136+ # Save the executed notebook.
135137 out_dir .mkdir (parents = True , exist_ok = True )
136- out_path = out_dir / f"{ py_path .stem } .html"
137- out_path .write_text (body , encoding = "utf-8" )
138- print (f" → { out_path } " )
138+ ipynb_path = out_dir / f"{ py_path .stem } .ipynb"
139+ with open (ipynb_path , "w" ) as f :
140+ nbformat .write (nb , f )
141+ print (f" saved { ipynb_path } " )
142+ return ipynb_path
139143
140144
141145# ---------------------------------------------------------------------------
@@ -149,8 +153,27 @@ def main() -> None:
149153 sys .exit (1 )
150154
151155 out_dir = Path (sys .argv [1 ])
152- for nb_path in (Path (p ) for p in sys .argv [2 :]):
153- execute_notebook (nb_path , out_dir )
156+ notebooks = [Path (p ) for p in sys .argv [2 :]]
157+
158+ # Phase 1 — execute all notebooks in-process (Metal GPU access required).
159+ ipynb_files : list [Path ] = []
160+ for nb_path in notebooks :
161+ ipynb_files .append (execute_notebook (nb_path , out_dir ))
162+
163+ # Phase 2 — convert to HTML in a subprocess so that nbconvert's heavy
164+ # imports (jinja2, lxml, …) are fully isolated from the Metal runtime.
165+ for ipynb_path in ipynb_files :
166+ subprocess .run (
167+ [
168+ sys .executable , "-m" , "nbconvert" ,
169+ "--to" , "html" ,
170+ "--output-dir" , str (out_dir ),
171+ str (ipynb_path ),
172+ ],
173+ check = True ,
174+ )
175+ ipynb_path .unlink ()
176+ print (f" → { out_dir / ipynb_path .with_suffix ('.html' ).name } " )
154177
155178
156179if __name__ == "__main__" :
0 commit comments