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

1"""The LaTeX tasks: paper.mk, as tasks. 

2 

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. 

7 

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: 

14 

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. 

23 

24The file choice is the other thing that changed, and it changed earlier. paper.mk reads 

25 

26 if [ -f $(PAPER_DIR)/basanos.tex ]; then tex_file="basanos.tex"; else <first *.tex>; fi 

27 

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. 

32 

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""" 

37 

38from __future__ import annotations 

39 

40from pathlib import Path 

41 

42from ..config import Config 

43from ..spec import Guard, Skip, task 

44from ..uv import tool 

45 

46SECTION = "Paper" 

47 

48HAVE_TECTONIC = Guard( 

49 tool="tectonic", 

50 reason="tectonic not found; see https://tectonic-typesetting.github.io/en-US/install.html", 

51) 

52 

53PREFERRED = ("main.tex", "paper.tex") 

54"""Root-document names tried before falling back to alphabetical order.""" 

55 

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. 

58 

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. 

63 

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. 

68 

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""" 

72 

73 

74def main_document(folder: Path) -> Path | None: 

75 """Choose the root ``.tex`` file in a folder. 

76 

77 Args: 

78 folder: The paper folder. 

79 

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]) 

88 

89 

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. 

93 

94 Args: 

95 cfg: The resolved config. 

96 

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}'") 

104 

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") 

116 

117 

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. 

121 

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. 

126 

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. 

132 

133 Args: 

134 cfg: The resolved config. 

135 

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") 

142 

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))")