Coverage for src/rhiza_task/tasks/paper.py: 100%
39 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""The LaTeX tasks: paper.mk, as tasks.
3Closer in shape to ``book`` than to the CLI wrappers: a build with an output worth
4naming. The engine does the hard part -- it reruns the TeX pass and bibtex until the
5citations and cross-references converge -- so the task is a folder, a file choice and a
6fixed flag set.
8**The engine is tectonic**, which is the one substantive change from paper.mk. paper.mk
9drove a full TeX distribution, so a consumer provisioned the distribution *and* the list
10of packages their document happened to cite, and the two workflows here each carried their
11own copy of that list. tectonic is a single binary that resolves what a document cites out
12of its web bundle and caches it, so there is one tool to install and no list to keep in
13step. Three consequences the flag set below records rather than restates:
15* Convergence and bibtex are the engine's own loop, not a driver's, so neither is asked
16 for -- the argument vector is the document and nothing about *how* to build it.
17* There is no interaction mode to pin. tectonic never stops for a prompt, so it needs no
18 flag saying so; a broken document exits non-zero, which :func:`~rhiza_task.uv.tool`
19 turns into :class:`~rhiza_task.spec.Failed`.
20* A cold cache needs the network. A provisioned distribution did not, and that is the one
21 thing this trade costs; the cache is per-machine and survives between runs, so it is a
22 first-run cost rather than a per-build one.
24The file choice is the other thing that changed, and it changed earlier. paper.mk reads
26 if [ -f $(PAPER_DIR)/basanos.tex ]; then tex_file="basanos.tex"; else <first *.tex>; fi
28-- a named preference for one downstream repository's paper, in a template every consumer
29syncs. :func:`main_document` replaces it with two conventional names and then alphabetical
30order, so the behaviour is the same for a folder with one ``.tex`` (the overwhelmingly
31common case) and no longer privileges a stranger's filename.
33``-maxdepth 1`` survives as :meth:`~pathlib.Path.glob` rather than
34:meth:`~pathlib.Path.rglob`, and deliberately: a LaTeX project's subdirectories hold
35included chapters, and the engine must be pointed at the root document, not at a chapter.
36"""
38from __future__ import annotations
40from pathlib import Path
42from ..config import Config
43from ..spec import Guard, Skip, task
44from ..uv import tool
46SECTION = "Paper"
48HAVE_TECTONIC = Guard(
49 tool="tectonic",
50 reason="tectonic not found; see https://tectonic-typesetting.github.io/en-US/install.html",
51)
53PREFERRED = ("main.tex", "paper.tex")
54"""Root-document names tried before falling back to alphabetical order."""
56AUX_SUFFIXES = (".aux", ".bbl", ".blg", ".log", ".out", ".synctex.gz", ".toc")
57"""What a TeX run leaves beside the document, mirroring .gitignore's list for this folder.
59The PDF is deliberately absent: this is the set that is *never* worth keeping, and both
60callers want it -- :func:`paper_clean` adds the PDF because removing the output is the
61point of a clean, and ``book``'s prune keeps the PDF because publishing it is the point of
62the build.
64These are the names TeX itself writes. A driver's own bookkeeping files -- the
65rebuild-cache and file-list a make-style LaTeX driver keeps -- are not listed, because no
66driver runs here: tectonic is the whole engine and writes the ``.log`` (asked for below)
67and, only when asked, the rest.
69Matched as name suffixes rather than through :attr:`~pathlib.PurePath.suffix`, because
70``.synctex.gz`` is two extensions and ``suffix`` would report only ``.gz``.
71"""
74def main_document(folder: Path) -> Path | None:
75 """Choose the root ``.tex`` file in a folder.
77 Args:
78 folder: The paper folder.
80 Returns:
81 The document to compile, or None when the folder holds no top-level ``.tex``.
82 """
83 candidates = sorted(p for p in folder.glob("*.tex") if p.is_file())
84 if not candidates:
85 return None
86 by_name = {p.name: p for p in candidates}
87 return next((by_name[name] for name in PREFERRED if name in by_name), candidates[0])
90@task("paper", "compile the LaTeX paper to PDF", section=SECTION, guards=(HAVE_TECTONIC, Guard("paper_folder")))
91def paper(cfg: Config) -> None:
92 """Run tectonic over the paper folder's root document.
94 Args:
95 cfg: The resolved config.
97 Raises:
98 Skip: When the folder holds no top-level ``.tex`` file.
99 """
100 folder = cfg.path("paper_folder")
101 document = main_document(folder)
102 if document is None:
103 raise Skip(f"no .tex files in '{cfg.paper_folder}'")
105 print(f"[INFO] compiling {document.name}")
106 # cwd is the paper folder, as `cd $(PAPER_DIR) && <engine>` was: tectonic resolves
107 # \input paths relative to the document and writes its output beside it, which is what
108 # lets `book` publish the PDF with no copy step.
109 #
110 # `--keep-logs` is the one flag, and it is not cosmetic: tectonic writes only the PDF by
111 # default, and on a runner the log is the artefact you upload when a compile fails. It
112 # is also the file `book`'s prune exists to keep out of the published site, since it
113 # records absolute paths from whichever machine built it.
114 tool("tectonic", "--keep-logs", document.name, cwd=folder)
115 print(f"[SUCCESS] {cfg.paper_folder}/{document.stem}.pdf")
118@task("paper-clean", "remove the LaTeX build artifacts", section=SECTION)
119def paper_clean(cfg: Config) -> None:
120 """Remove the PDF and auxiliary files belonging to the folder's top-level documents.
122 Pure Python, and unguarded on any tool: tectonic has no clean subcommand to delegate
123 to, so there is nothing to be absent. That makes this the one task in the section that
124 works on a machine which cannot build the paper at all -- an improvement over
125 delegating, where cleaning required the very toolchain you were cleaning up after.
127 **Scoped by document stem, not by extension sweep.** ``paper.tex`` authorises deleting
128 ``paper.pdf`` and ``paper.log``; a ``figures/`` diagram exported to ``diagram.pdf`` and
129 committed beside the source has no ``diagram.tex`` and survives. An extension sweep
130 would be one line shorter and would delete a consumer's checked-in artwork, which is
131 not recoverable by rebuilding.
133 Args:
134 cfg: The resolved config.
136 Raises:
137 Skip: When there is no paper folder to clean.
138 """
139 folder = cfg.path("paper_folder")
140 if not folder.is_dir():
141 raise Skip(f"paper_folder '{cfg.paper_folder}' not found")
143 removed = 0
144 for stem in sorted({p.stem for p in folder.glob("*.tex") if p.is_file()}):
145 for suffix in (*AUX_SUFFIXES, ".pdf"):
146 artifact = folder / f"{stem}{suffix}"
147 if artifact.is_file():
148 artifact.unlink()
149 removed += 1
150 # Cleaning a folder that was never built leaves nothing to report and is not a failure,
151 # which is what paper.mk's `|| true` bought; here it falls out of there being no tool
152 # to fail.
153 print(f"[SUCCESS] cleaned {cfg.paper_folder} ({removed} file(s))")