File indexing completed on 2026-11-05 09:36:12
0001
0002
0003
0004
0005
0006
0007
0008
0009
0010
0011
0012
0013
0014
0015
0016
0017
0018
0019
0020 """
0021 Execute a jupytext notebook and render it as a doxygen page.
0022
0023 Several of the sympy-based code generators are written in jupytext's `percent`
0024 format: plain Python that the build runs as a script, and that a notebook
0025 viewer opens as a notebook whose prose explains the derivation. This turns one
0026 of those into a markdown page for the doxygen site, so the explanation is
0027 published with the outputs -- matrices, operation counts, the generated C++ --
0028 filled in by actually running it.
0029
0030 The pipeline is jupytext -> execute -> nbconvert markdown -> doxygen-flavoured
0031 markdown. Only the last step is specific to us: nbconvert emits LaTeX between
0032 `$` delimiters, which doxygen's markdown parser mangles before MathJax ever
0033 sees it, so those are rewritten to doxygen's `\\f$ ... \\f$` form, which
0034 doxygen passes through verbatim.
0035
0036 Anything this needs beyond its own dependencies -- everything the notebook
0037 imports -- has to be in the environment it is run in, e.g. via `uv run --with`.
0038 """
0039
0040 import re
0041 import sys
0042 from importlib.metadata import distribution
0043 from pathlib import Path
0044 from typing import Annotated
0045
0046 import jupytext
0047 import typer
0048 from nbconvert import MarkdownExporter
0049 from nbconvert.preprocessors import ExecutePreprocessor
0050
0051 app = typer.Typer(add_completion=False)
0052
0053 FENCE = re.compile(r"^\s*```")
0054
0055
0056 INDENTED = re.compile(r"^ {4}\S")
0057 DISPLAY_MATH = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
0058 INLINE_MATH = re.compile(r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)", re.DOTALL)
0059
0060
0061 def to_doxygen_math(markdown: str) -> str:
0062 """Rewrite `$...$` and `$$...$$` into doxygen's formula commands.
0063
0064 Doxygen only leaves LaTeX alone inside `\\f$ ... \\f$` (inline) and
0065 `\\f[ ... \\f]` (display); everywhere else it eats the backslashes and
0066 underscores first, and then errors out on what is left.
0067
0068 Formulas are matched per paragraph rather than per line, because prose
0069 wraps and an inline formula can straddle the break. A blank line still
0070 ends the search: pairing `$` across a paragraph boundary would join two
0071 unrelated formulas into one, which is worse than not matching at all.
0072 """
0073
0074 result = []
0075 paragraph = []
0076 in_fence = False
0077
0078 def flush_paragraph():
0079 if not paragraph:
0080 return
0081 chunk = "\n".join(paragraph)
0082 chunk = DISPLAY_MATH.sub(r"\\f[\1\\f]", chunk)
0083 chunk = INLINE_MATH.sub(r"\\f$\1\\f$", chunk)
0084 if "$" in chunk.replace("\\f$", ""):
0085
0086
0087 print(
0088 f"warning: unpaired '$' in\n{chunk}\n"
0089 "put literal dollar signs in `code spans`",
0090 file=sys.stderr,
0091 )
0092 result.append(chunk)
0093 paragraph.clear()
0094
0095 for line in markdown.split("\n"):
0096 if FENCE.match(line):
0097 flush_paragraph()
0098 in_fence = not in_fence
0099 result.append(line)
0100 elif in_fence or not line.strip() or INDENTED.match(line):
0101 flush_paragraph()
0102 result.append(line)
0103 else:
0104 paragraph.append(line)
0105 flush_paragraph()
0106
0107 return "\n".join(result)
0108
0109
0110 def add_page_id(markdown: str, page_id: str, note: str) -> str:
0111 """Label the leading `# Title` so other pages can `@ref` it.
0112
0113 The label is also what doxygen names the output file, giving the page a URL
0114 that does not move when the build directory does -- but only when the
0115 labelled title really is the first thing in the file, which is why `note`
0116 goes underneath it rather than above.
0117 """
0118
0119 lines = markdown.split("\n")
0120 for i, line in enumerate(lines):
0121 if line.startswith("# "):
0122 lines[i] = f"{line.rstrip()} {{#{page_id}}}"
0123 lines.insert(i + 1, f"\n<!-- {note} -->")
0124 return "\n".join(lines)
0125 raise ValueError("notebook has no leading '# Title' markdown heading")
0126
0127
0128 @app.command()
0129 def main(
0130 source: Annotated[
0131 Path,
0132 typer.Argument(help="jupytext notebook to execute, in percent format"),
0133 ],
0134 output: Annotated[Path, typer.Argument(help="markdown file to write for doxygen")],
0135 page_id: Annotated[
0136 str, typer.Option(help="doxygen page label to attach to the title")
0137 ],
0138 timeout: Annotated[
0139 int, typer.Option(help="seconds a single cell may run for")
0140 ] = 600,
0141 ) -> None:
0142 notebook = jupytext.read(source)
0143
0144
0145
0146 ExecutePreprocessor(timeout=timeout, kernel_name="python3").preprocess(
0147 notebook, {"metadata": {"path": str(source.parent)}}
0148 )
0149
0150
0151
0152
0153 nbconvert_dist = distribution("nbconvert")
0154 template_basedirs = [
0155 str(nbconvert_dist.locate_file(file).parent.parent)
0156 for file in nbconvert_dist.files or []
0157 if file.as_posix().endswith("nbconvert/templates/markdown/conf.json")
0158 ]
0159
0160
0161 markdown, resources = MarkdownExporter(
0162 extra_template_basedirs=template_basedirs,
0163 extra_template_paths=template_basedirs,
0164 ).from_notebook_node(notebook)
0165 markdown = add_page_id(
0166 to_doxygen_math(markdown),
0167 page_id,
0168 f"Generated from {source.name} by docs/render_notebook.py. "
0169 "Edit that notebook, not this file.",
0170 )
0171
0172 output.parent.mkdir(parents=True, exist_ok=True)
0173 output.write_text(markdown)
0174
0175
0176
0177 for name, data in (resources.get("outputs") or {}).items():
0178 (output.parent / name).write_bytes(data)
0179
0180
0181 if __name__ == "__main__":
0182 app()