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

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 

90def has_paper(cfg: Config) -> bool: 

91 """Return whether the repository has a paper, for ``list``. 

92 

93 Args: 

94 cfg: The resolved config. 

95 

96 Returns: 

97 True when ``paper_folder`` exists -- the folder both tasks skip without. 

98 """ 

99 return (cfg.root / cfg.paper_folder).is_dir() 

100 

101 

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. 

111 

112 Args: 

113 cfg: The resolved config. 

114 

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

122 

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

134 

135 

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. 

139 

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. 

144 

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. 

150 

151 Args: 

152 cfg: The resolved config. 

153 

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

160 

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