From 29505c663527c4b9e1a079cc5f2ae7dc12207174 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 21 Jan 2025 13:57:31 -0800 Subject: [PATCH] New build script --- tools/build/main.py | 285 ++++++++++++++++++++++++++++++++++++++++++ tools/build/ruff.toml | 17 +++ 2 files changed, 302 insertions(+) create mode 100644 tools/build/main.py create mode 100644 tools/build/ruff.toml diff --git a/tools/build/main.py b/tools/build/main.py new file mode 100644 index 0000000..2bd7257 --- /dev/null +++ b/tools/build/main.py @@ -0,0 +1,285 @@ +from typing import TypedDict +from pathlib import Path +import subprocess +import tomllib +import shutil +import json +import os + +# TODO: +# list handouts without solutions +# list handouts that are not published +# lint: always commit without `nosolutions` +# use latexmk + +ROOT: Path = Path(os.getcwd()) + +### CONFIGURATION +OUT_DIR: Path = ROOT / "output" +TYPST_PATH = "typst" +XETEX_PATH = "xelatex" +### END CONFIGURATION + + +def log(msg): + print(f"[BUILD.PY] {msg}") + + +log(f"Running in {ROOT}") +if not ROOT.is_dir(): + log("Root is not a directory, cannot continue") + exit(1) + +log(f"Output dir is {OUT_DIR}") +if OUT_DIR.exists(): + log("Output dir exists, removing") + shutil.rmtree(OUT_DIR) + +OUT_DIR.mkdir(parents=True) + + +IndexEntry = TypedDict( + "IndexEntry", + {"title": str, "group": str, "handout_file": str, "solutions_file": str | None}, +) + + +MetaToml = TypedDict( + "MetaToml", {"title": str, "publish_handout": bool, "publish_solutions": bool} +) + + +def read_meta_toml(file: Path) -> MetaToml: + with file.open("rb") as f: + base = tomllib.load(f) + + meta = base.get("metadata") + if not isinstance(meta, dict): + log("Invalid meta.toml: `metadata` should be a table") + exit(1) + + title = meta.get("title") + if not isinstance(title, str): + log("Invalid meta.toml: `title` should be a string") + exit(1) + + pub = base.get("publish") + if not isinstance(pub, dict): + log("Invalid meta.toml: `publish` should be a table") + exit(1) + + pub_handout = pub.get("handout") + if not isinstance(pub_handout, bool): + log("Invalid meta.toml: `publish.handout` should be a boolean") + exit(1) + + pub_solutions = pub.get("solutions") + if not isinstance(pub_solutions, bool): + log("Invalid meta.toml: `publish.solutions` should be a boolean") + exit(1) + + return { + "title": title, + "publish_handout": pub_handout, + "publish_solutions": pub_solutions, + } + + +def log_error(res): + stdout = res.stdout.decode("utf-8").strip() + stderr = res.stderr.decode("utf-8").strip() + + log(f"Build failed with code {res.returncode}") + + if stdout == "": + log("Stdout: ") + else: + log("Stdout:") + print(stdout) + + if stderr == "": + log("Stderr: ") + else: + log("Stderr:") + print(stderr) + + exit(1) + + +def build_typst(source_dir: Path, out_subdir: Path) -> IndexEntry | None: + if not (source_dir / "main.typ").is_file(): + # log(f"No main.typ, skipping {source_dir}") + return None + + meta_path = source_dir / "meta.toml" + if not meta_path.is_file(): + log(f"No meta.toml, skipping {source_dir}") + return None + meta = read_meta_toml(meta_path) + + # Do nothing if not published + if not meta["publish_handout"]: + return None + + # Build handout + log(f"Building typst (handout) : {source_dir}") + + out = OUT_DIR / out_subdir + out.mkdir(parents=True, exist_ok=True) + + res = subprocess.run( + [ + TYPST_PATH, + "compile", + "--ignore-system-fonts", + "main.typ", + "--input", + "show_solutions=false", + f"{out}/{meta['title']}.pdf", + ], + cwd=source_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if res.returncode != 0: + log_error(res) + + # Build solutions + if meta["publish_solutions"]: + log(f"Building typst (solutions): {source_dir}") + res = subprocess.run( + [ + TYPST_PATH, + "compile", + "--ignore-system-fonts", + "main.typ", + f"{out}/{meta['title']}.sols.pdf", + ], + cwd=source_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if res.returncode != 0: + log_error(res) + + return { + "title": meta["title"], + "group": str(out_subdir), + "handout_file": f"{out_subdir}/{meta['title']}.pdf", + "solutions_file": ( + f"{out_subdir}/{meta['title']}.sols.pdf" + if meta["publish_solutions"] + else None + ), + } + + +def build_xetex(source_dir: Path, out_subdir: Path) -> IndexEntry | None: + if not (source_dir / "main.tex").is_file(): + # log(f"No main.tex, skipping {source_dir}") + return None + + meta_path = source_dir / "meta.toml" + if not meta_path.is_file(): + log(f"No meta.toml, skipping {source_dir}") + return None + meta = read_meta_toml(meta_path) + + # Do nothing if not published + if not meta["publish_handout"]: + return None + + # Build handout + log(f"Building xetex (handout) : {source_dir}") + + out = OUT_DIR / out_subdir + out.mkdir(parents=True, exist_ok=True) + + res = subprocess.run( + [ + XETEX_PATH, + "-interaction=batchmode", + ], + input=b"\\def\\argNoSolutions{1}\\input{main.tex}", + cwd=source_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + shutil.copy(source_dir / "main.pdf", f"{out}/{meta['title']}.pdf") + + if res.returncode != 0: + log_error(res) + + # Build solutions + if meta["publish_solutions"]: + log(f"Building xetex (solutions): {source_dir}") + res = subprocess.run( + [ + XETEX_PATH, + "-interaction=batchmode", + ], + input=b"\\def\\argYesSolutions{1}\\input{main.tex}", + cwd=source_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + shutil.copy(source_dir / "main.pdf", f"{out}/{meta['title']}.sols.pdf") + + if res.returncode != 0: + log_error(res) + + return { + "title": meta["title"], + "group": str(out_subdir), + "handout_file": f"{out_subdir}/{meta['title']}.pdf", + "solutions_file": ( + f"{out_subdir}/{meta['title']}.sols.pdf" + if meta["publish_solutions"] + else None + ), + } + + +# Try to build handouts in all subdirs of `base`, +# skipping those that do not contain a handout. +# +# This method does _not_ recurse into subdirectories. +def build_dir(base: str, out_sub: str, index: list[IndexEntry]): + builders = [build_typst, build_xetex] + + for d_str in os.listdir(base): + d = base / Path(d_str) + if not d.is_dir(): + continue + + # Try each builder, + # stopping at the first success + done = False + for builder in builders: + res = builder(d, Path(out_sub)) + if res is not None: + # Check for duplicate titles + for i in index: + if i["title"] == res["title"]: + log(f'Duplicate title "{i["title"]}", cannot continue') + exit(1) + # Save to index and exit + index.append(res) + done = True + if done: + break + + return index + + +index: list[IndexEntry] = [] + +index.extend(build_dir("Advanced", "Advanced", index)) +index.extend(build_dir("Intermediate", "Intermediate", index)) + +with open(OUT_DIR / "index.json", "w") as f: + f.write(json.dumps(index)) diff --git a/tools/build/ruff.toml b/tools/build/ruff.toml new file mode 100644 index 0000000..f110dc4 --- /dev/null +++ b/tools/build/ruff.toml @@ -0,0 +1,17 @@ +exclude = ["venv"] +line-length = 88 +indent-width = 4 +target-version = "py39" + +[lint] +select = ["E4", "E7", "E9", "F"] +ignore = [] +fixable = ["ALL"] +unfixable = [] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +quote-style = "double" +indent-style = "tab" +skip-magic-trailing-comma = false +line-ending = "lf"