2025-01-22 21:01:26 -08:00

303 lines
6.5 KiB
Python

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: str = "typst"
XETEX_PATH: str = "xelatex"
### END CONFIGURATION
# Allow path override
_env = os.environ.get("TYPST_PATH")
if isinstance(_env, str):
TYPST_PATH = _env
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 sanitize_file_name(file: str) -> str:
return file.replace("?", "")
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: <empty>")
else:
log("Stdout:")
print(stdout)
if stderr == "":
log("Stderr: <empty>")
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)
handout_file = sanitize_file_name(f"{meta['title']}.pdf")
solutions_file = sanitize_file_name(f"{meta['title']}.sols.pdf")
# 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}/{handout_file}",
],
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}/{solutions_file}",
],
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": str(out / handout_file),
"solutions_file": (
str(out / solutions_file) 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)
handout_file = sanitize_file_name(f"{meta['title']}.pdf")
solutions_file = sanitize_file_name(f"{meta['title']}.sols.pdf")
# 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,
)
try:
shutil.copy(source_dir / "main.pdf", f"{out}/{handout_file}")
except Exception as e:
log(f"Error: {e}")
log_error(res)
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,
)
try:
shutil.copy(source_dir / "main.pdf", f"{out}/{solutions_file}")
except Exception as e:
log(f"Error: {e}")
log_error(res)
if res.returncode != 0:
log_error(res)
return {
"title": meta["title"],
"group": str(out_subdir),
"handout_file": str(out / handout_file),
"solutions_file": (
str(out / solutions_file) 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
index: list[IndexEntry] = []
build_dir("src/Advanced", "Advanced", index)
build_dir("src/Intermediate", "Intermediate", index)
build_dir("src/Warm-Ups", "Warm-Ups", index)
with open(OUT_DIR / "index.json", "w") as f:
f.write(json.dumps(index))