|
| 1 | +"""Build the Jupyter Book of "Quantum Machine Learning" (a copy of the |
| 2 | +script of the same name in doc/Book/Notebooks, with the paths of BookQML). |
| 3 | +
|
| 4 | + cd doc/Book/BookQML/Notebooks |
| 5 | + python3 build_book.py # merge the bibliographies, check the table of contents, build the HTML |
| 6 | + python3 build_book.py --regenerate # first regenerate every notebook of _toc.yml from the LaTeX |
| 7 | + python3 build_book.py --execute # re-execute all notebooks during the build (a few minutes) |
| 8 | + python3 build_book.py --clean # remove _build first |
| 9 | + python3 build_book.py --check # everything except the build itself (no jupyter-book needed) |
| 10 | +
|
| 11 | +The book is the set of generated chapter notebooks chapterN/chapterN.ipynb |
| 12 | +(see README.md; never edit them by hand) plus intro.md and references.md. |
| 13 | +Three things are prepared before ``jupyter-book build .`` is called: |
| 14 | +
|
| 15 | +1. references.bib is merged from ../chapter*.bib. The chapter files repeat |
| 16 | + entries that several chapters cite; sphinxcontrib-bibtex refuses duplicate |
| 17 | + keys, so the first occurrence is kept and a differing repeat is reported. |
| 18 | +2. Every file of _toc.yml must exist, and every chapter notebook that exists |
| 19 | + must be in _toc.yml (a commented-out entry is reported, not an error). |
| 20 | +3. Every figure a notebook refers to (../../BookFigures/chapterN/*.png) must |
| 21 | + exist; run the chapter programs and BookPrograms/render_tikz.py if not. |
| 22 | +
|
| 23 | +Only the standard library is needed for the preparation; the build needs the |
| 24 | +packages of requirements.txt. |
| 25 | +""" |
| 26 | +import argparse, json, pathlib, re, shutil, subprocess, sys |
| 27 | + |
| 28 | +HERE = pathlib.Path(__file__).resolve().parent # doc/Book/BookQML/Notebooks |
| 29 | +BOOK = HERE.parent # doc/Book/BookQML |
| 30 | + |
| 31 | + |
| 32 | +def split_bib(text): |
| 33 | + """Yield (key, entry) for every @type{key, ...} of a BibTeX file, by brace counting.""" |
| 34 | + for m in re.finditer(r"@(\w+)\s*\{\s*([^,\s]+)\s*,", text): |
| 35 | + if m.group(1).lower() in ("comment", "string", "preamble"): |
| 36 | + continue |
| 37 | + depth, i = 0, m.start() |
| 38 | + while i < len(text): |
| 39 | + if text[i] == "{": |
| 40 | + depth += 1 |
| 41 | + elif text[i] == "}": |
| 42 | + depth -= 1 |
| 43 | + if depth == 0: |
| 44 | + break |
| 45 | + i += 1 |
| 46 | + yield m.group(2), text[m.start():i + 1] |
| 47 | + |
| 48 | + |
| 49 | +def merge_bibliographies(): |
| 50 | + entries, origin, clashes = {}, {}, [] |
| 51 | + files = sorted(BOOK.glob("chapter*.bib"), key=lambda p: int(re.sub(r"\D", "", p.stem) or 0)) |
| 52 | + for bib in files: |
| 53 | + for key, entry in split_bib(bib.read_text(encoding="utf-8")): |
| 54 | + if key in entries: |
| 55 | + if re.sub(r"\s+", "", entry) != re.sub(r"\s+", "", entries[key]): |
| 56 | + clashes.append(f"{key}: {bib.name} differs from {origin[key]} (kept {origin[key]})") |
| 57 | + continue |
| 58 | + entries[key], origin[key] = entry, bib.name |
| 59 | + header = ("% Generated by build_book.py from " + ", ".join(b.name for b in files) |
| 60 | + + " -- do not edit; edit the chapter .bib files.\n\n") |
| 61 | + (HERE / "references.bib").write_text(header + "\n\n".join(entries[k] for k in sorted(entries, key=str.lower)) + "\n", |
| 62 | + encoding="utf-8") |
| 63 | + print(f"references.bib: {len(entries)} entries from {len(files)} files") |
| 64 | + for c in clashes: |
| 65 | + print(" note, duplicate key with different text --", c) |
| 66 | + return entries |
| 67 | + |
| 68 | + |
| 69 | +def toc_files(): |
| 70 | + text = (HERE / "_toc.yml").read_text(encoding="utf-8") |
| 71 | + active = re.findall(r"^\s*-?\s*(?:file|root):\s*(\S+)", text, flags=re.M) |
| 72 | + commented = re.findall(r"^\s*#\s*-\s*file:\s*(\S+)", text, flags=re.M) |
| 73 | + return active, commented |
| 74 | + |
| 75 | + |
| 76 | +def resolve(name): |
| 77 | + for ext in ("", ".ipynb", ".md"): |
| 78 | + p = HERE / (name + ext) |
| 79 | + if p.is_file(): |
| 80 | + return p |
| 81 | + return None |
| 82 | + |
| 83 | + |
| 84 | +def check(entries): |
| 85 | + ok = True |
| 86 | + active, commented = toc_files() |
| 87 | + for name in active: |
| 88 | + if resolve(name) is None: |
| 89 | + print(f"MISSING: _toc.yml lists {name} but there is no such notebook or page"); ok = False |
| 90 | + listed = {str(resolve(n)) for n in active if resolve(n)} |
| 91 | + for nb in sorted(HERE.glob("chapter*/chapter*.ipynb")): |
| 92 | + rel = nb.relative_to(HERE).with_suffix("").as_posix() |
| 93 | + if str(nb) not in listed: |
| 94 | + where = "commented out in" if rel in commented else "absent from" |
| 95 | + print(f"note: {rel}.ipynb exists but is {where} _toc.yml") |
| 96 | + cited = set() |
| 97 | + for name in active: |
| 98 | + p = resolve(name) |
| 99 | + if p is None or p.suffix != ".ipynb": |
| 100 | + continue |
| 101 | + nb = json.loads(p.read_text(encoding="utf-8")) |
| 102 | + md = "\n".join("".join(c["source"]) for c in nb["cells"] if c["cell_type"] == "markdown") |
| 103 | + for img in re.findall(r"!\[[^\]]*\]\(([^)\s]+)\)", md): |
| 104 | + if not img.startswith("http") and not (p.parent / img).resolve().is_file(): |
| 105 | + print(f"MISSING figure in {p.name}: {img}"); ok = False |
| 106 | + for group in re.findall(r"\[([A-Za-z][\w:.+-]*\d{4}\w*(?:,\s*[A-Za-z][\w:.+-]*\d{4}\w*)*)\]", md): |
| 107 | + cited.update(k.strip() for k in group.split(",")) |
| 108 | + n_code = sum(c["cell_type"] == "code" for c in nb["cells"]) |
| 109 | + n_out = sum(c["cell_type"] == "code" and bool(c.get("outputs")) for c in nb["cells"]) |
| 110 | + if n_code > 1 and n_out == 0: |
| 111 | + print(f"note: {p.name} has no stored outputs; execute it (README.md) or build with --execute") |
| 112 | + unknown = sorted(k for k in cited if k not in entries) |
| 113 | + if unknown: |
| 114 | + print("note: cited in the notebooks but in no chapter .bib file:", ", ".join(unknown)) |
| 115 | + return ok |
| 116 | + |
| 117 | + |
| 118 | +def regenerate(): |
| 119 | + active, _ = toc_files() |
| 120 | + numbers = sorted({int(m.group(1)) for n in active if (m := re.match(r"chapter(\d+)/", n))}) |
| 121 | + cmd = [sys.executable, str(BOOK / "BookPrograms" / "tex_to_notebook.py"), str(HERE), ",".join(map(str, numbers))] |
| 122 | + print(" ".join(cmd)); subprocess.check_call(cmd, cwd=BOOK) |
| 123 | + print("The regenerated notebooks have no outputs: execute them (README.md) or build with --execute.") |
| 124 | + |
| 125 | + |
| 126 | +if __name__ == "__main__": |
| 127 | + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) |
| 128 | + ap.add_argument("--regenerate", action="store_true", help="regenerate the notebooks of _toc.yml from the LaTeX first") |
| 129 | + ap.add_argument("--execute", action="store_true", help="re-execute every notebook during the build") |
| 130 | + ap.add_argument("--clean", action="store_true", help="remove _build before building") |
| 131 | + ap.add_argument("--check", action="store_true", help="prepare and check only, do not build") |
| 132 | + args = ap.parse_args() |
| 133 | + |
| 134 | + if args.regenerate: |
| 135 | + regenerate() |
| 136 | + good = check(merge_bibliographies()) |
| 137 | + if not good: |
| 138 | + sys.exit("Fix the problems above before building.") |
| 139 | + if args.check: |
| 140 | + sys.exit(0) |
| 141 | + if args.clean and (HERE / "_build").exists(): |
| 142 | + shutil.rmtree(HERE / "_build") |
| 143 | + exe = shutil.which("jupyter-book") or shutil.which("jb") |
| 144 | + if exe is None: |
| 145 | + sys.exit("jupyter-book is not installed: pip install -r requirements.txt") |
| 146 | + version = subprocess.run([exe, "--version"], capture_output=True, text=True).stdout |
| 147 | + m = re.search(r"Jupyter Book\s*:\s*(\d+)", version) or re.search(r"(\d+)\.\d+", version) |
| 148 | + if m and int(m.group(1)) >= 2: |
| 149 | + sys.exit("This book is configured for Jupyter Book 1.x (_config.yml, _toc.yml): pip install -r requirements.txt") |
| 150 | + cmd = [exe, "build", str(HERE)] |
| 151 | + if args.execute: |
| 152 | + cmd += ["--config", str(HERE / "_config_execute.yml")] |
| 153 | + cfg = (HERE / "_config.yml").read_text(encoding="utf-8").replace('execute_notebooks: "off"', 'execute_notebooks: "force"') |
| 154 | + (HERE / "_config_execute.yml").write_text(cfg, encoding="utf-8") |
| 155 | + print(" ".join(cmd)) |
| 156 | + code = subprocess.call(cmd) |
| 157 | + if code == 0: |
| 158 | + print(f"\nOpen {HERE / '_build' / 'html' / 'index.html'}") |
| 159 | + sys.exit(code) |
0 commit comments