Coverage for src/rhiza_task/tasks/paper.py: 100%
41 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +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])
90def has_paper(cfg: Config) -> bool:
91 """Return whether the repository has a paper, for ``list``.
93 Args:
94 cfg: The resolved config.
96 Returns:
97 True when ``paper_folder`` exists -- the folder both tasks skip without.
98 """
99 return (cfg.root / cfg.paper_folder).is_dir()
102@task(
103 "paper",
104 "compile the LaTeX paper to PDF",
105 section=SECTION,
106 guards=(HAVE_TECTONIC, Guard("paper_folder")),
107 applies=has_paper,
108)
109def paper(cfg: Config) -> None:
110 """Run tectonic over the paper folder's root document.
112 Args:
113 cfg: The resolved config.
115 Raises:
116 Skip: When the folder holds no top-level ``.tex`` file.
117 """
118 folder = cfg.path("paper_folder")
119 document = main_document(folder)
120 if document is None:
121 raise Skip(f"no .tex files in '{cfg.paper_folder}'")
123 print(f"[INFO] compiling {document.name}")
124 # cwd is the paper folder, as `cd $(PAPER_DIR) && <engine>` was: tectonic resolves
125 # \input paths relative to the document and writes its output beside it, which is what
126 # lets `book` publish the PDF with no copy step.
127 #
128 # `--keep-logs` is the one flag, and it is not cosmetic: tectonic writes only the PDF by
129 # default, and on a runner the log is the artefact you upload when a compile fails. It
130 # is also the file `book`'s prune exists to keep out of the published site, since it
131 # records absolute paths from whichever machine built it.
132 tool("tectonic", "--keep-logs", document.name, cwd=folder)
133 print(f"[SUCCESS] {cfg.paper_folder}/{document.stem}.pdf")
136@task("paper-clean", "remove the LaTeX build artifacts", section=SECTION, applies=has_paper)
137def paper_clean(cfg: Config) -> None:
138 """Remove the PDF and auxiliary files belonging to the folder's top-level documents.
140 Pure Python, and unguarded on any tool: tectonic has no clean subcommand to delegate
141 to, so there is nothing to be absent. That makes this the one task in the section that
142 works on a machine which cannot build the paper at all -- an improvement over
143 delegating, where cleaning required the very toolchain you were cleaning up after.
145 **Scoped by document stem, not by extension sweep.** ``paper.tex`` authorises deleting
146 ``paper.pdf`` and ``paper.log``; a ``figures/`` diagram exported to ``diagram.pdf`` and
147 committed beside the source has no ``diagram.tex`` and survives. An extension sweep
148 would be one line shorter and would delete a consumer's checked-in artwork, which is
149 not recoverable by rebuilding.
151 Args:
152 cfg: The resolved config.
154 Raises:
155 Skip: When there is no paper folder to clean.
156 """
157 folder = cfg.path("paper_folder")
158 if not folder.is_dir():
159 raise Skip(f"paper_folder '{cfg.paper_folder}' not found")
161 removed = 0
162 for stem in sorted({p.stem for p in folder.glob("*.tex") if p.is_file()}):
163 for suffix in (*AUX_SUFFIXES, ".pdf"):
164 artifact = folder / f"{stem}{suffix}"
165 if artifact.is_file():
166 artifact.unlink()
167 removed += 1
168 # Cleaning a folder that was never built leaves nothing to report and is not a failure,
169 # which is what paper.mk's `|| true` bought; here it falls out of there being no tool
170 # to fail.
171 print(f"[SUCCESS] cleaned {cfg.paper_folder} ({removed} file(s))")