Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-05 09:36:12

0001 #!/usr/bin/env python3
0002 # /// script
0003 # requires-python = ">=3.11"
0004 # dependencies = [
0005 #     "ipykernel",
0006 #     "jupytext",
0007 #     "nbconvert",
0008 #     "typer",
0009 # ]
0010 # ///
0011 
0012 # This file is part of the ACTS project.
0013 #
0014 # Copyright (C) 2016 CERN for the benefit of the ACTS project
0015 #
0016 # This Source Code Form is subject to the terms of the Mozilla Public
0017 # License, v. 2.0. If a copy of the MPL was not distributed with this
0018 # file, You can obtain one at https://mozilla.org/MPL/2.0/.
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 # nbconvert renders stream and text/plain output as indented blocks; they are
0055 # code, not prose, and must not be touched.
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             # An odd delimiter: whatever was meant as a formula is now half
0086             # rewritten. Doxygen will fail on it, so say why here first.
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     # Run in the notebook's own directory: relative paths in a notebook are
0145     # written with respect to where it lives, not where the build happens to be.
0146     ExecutePreprocessor(timeout=timeout, kernel_name="python3").preprocess(
0147         notebook, {"metadata": {"path": str(source.parent)}}
0148     )
0149 
0150     # uv can install this script and its --with dependencies in separate
0151     # environments. Jupyter's default search paths then follow sys.prefix
0152     # rather than the environment containing nbconvert's template data.
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     # Template discovery uses the basedirs, while Jinja also needs the root
0160     # itself to resolve qualified includes such as base/display_priority.j2.
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     # Figures, if the notebook ever grows any. The docs build puts this
0176     # directory on doxygen's IMAGE_PATH so they resolve.
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()