Coverage for texgit / run.py: 89%

149 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-22 02:50 +0000

1"""Process a LaTeX aux file.""" 

2import argparse 

3from os.path import dirname, getsize 

4from typing import Final, Generator 

5 

6from pycommons.io.arguments import make_argparser, make_epilog 

7from pycommons.io.console import logger 

8from pycommons.io.path import Path, directory_path, write_lines 

9from pycommons.strings.string_tools import escape, unescape 

10 

11from texgit.repository.git_manager import GitPath 

12from texgit.repository.process_manager import ProcessManager 

13from texgit.version import __version__ 

14 

15#: the header for git file requests 

16REQUEST_GIT_FILE: Final[str] = r"\@texgit@gitFile" 

17#: the header for argument file requests 

18REQUEST_ARG_FILE: Final[str] = r"\@texgit@argFile" 

19#: the header for process result requests 

20REQUEST_PROCESS: Final[str] = r"\@texgit@process" 

21#: the forbidden line marker that needs to be purged 

22FORBIDDEN_LINE: Final[str] = r"\@texgit@needsTexgitPass" 

23 

24#: the replacements 

25__REPL: Final[dict[str, str]] = { 

26 r"\\": "\\", r"\{": "{", "{{": "{", r"\}": "}", "}}": "}", r"\ ": " ", 

27} 

28 

29 

30def __get_request(line: str) -> list[str | None] | None: 

31 r""" 

32 Get the repository request, if any. 

33 

34 :param line: the line 

35 :return: the request, composed of the request function, the repository 

36 (if any), the path (if any), and the optional command; or `None` if 

37 no request was found. 

38 

39 >>> print(__get_request("")) 

40 None 

41 >>> print(__get_request(r"\hello")) 

42 None 

43 >>> print(__get_request(r"\@texgit@gitFile{x}{y}{}")) 

44 ['\\@texgit@gitFile', 'x', 'y', None] 

45 >>> print(__get_request(r"\@texgit@process{x}{y}{python3 --version}")) 

46 ['\\@texgit@process', 'x', 'y', 'python3', '--version'] 

47 >>> print(__get_request(r"\@texgit@gitFile{x}{y}{a}")) 

48 ['\\@texgit@gitFile', 'x', 'y', 'a'] 

49 >>> print(__get_request(r"\@texgit@gitFile{x}{y}{a b}")) 

50 ['\\@texgit@gitFile', 'x', 'y', 'a', 'b'] 

51 >>> print(__get_request(r"\@texgit@gitFile{x}{y}{a\ b}")) 

52 ['\\@texgit@gitFile', 'x', 'y', 'a b'] 

53 >>> print(__get_request(r"\@texgit@gitFile{x{{y}{y}{a\ b}")) 

54 ['\\@texgit@gitFile', 'x{y', 'y', 'a b'] 

55 >>> print(__get_request(r"\@texgit@gitFile{x\{y}{y}{a\ b}")) 

56 ['\\@texgit@gitFile', 'x{y', 'y', 'a b'] 

57 >>> print(__get_request(r"\@texgit@gitFile{x\{y}{}}y}{a\ b}")) 

58 ['\\@texgit@gitFile', 'x{y', '}y', 'a b'] 

59 >>> print(__get_request(r"\@texgit@gitFile{x\{y}{}}y}{a\ \\b}")) 

60 ['\\@texgit@gitFile', 'x{y', '}y', 'a \\b'] 

61 >>> print(__get_request(r"\@texgit@gitFile {x\{y}{}}y}{a\ \\b}")) 

62 ['\\@texgit@gitFile', 'x{y', '}y', 'a \\b'] 

63 >>> print(__get_request( 

64 ... r" \@texgit@gitFile { x\{y}{ }}y }{ a\ \\b } ")) 

65 ['\\@texgit@gitFile', 'x{y', '}y', 'a \\b'] 

66 >>> print(__get_request( 

67 ... r" \@texgit@argFile { x\{y}{ }}y }{ a\ \\b } {xx} {y }")) 

68 ['\\@texgit@argFile', 'x{y', '}y', 'a \\b', 'xx', 'y'] 

69 """ 

70 use_line = str.strip(line) 

71 if str.__len__(use_line) >= 67108864: 

72 raise ValueError(f"line is {len(use_line)} characters long?") 

73 

74 request: Final[str | None] = REQUEST_GIT_FILE if str.startswith( 

75 use_line, REQUEST_GIT_FILE) else (REQUEST_ARG_FILE if str.startswith( 

76 use_line, REQUEST_ARG_FILE) else ( 

77 REQUEST_PROCESS if str.startswith(use_line, REQUEST_PROCESS) 

78 else None)) 

79 if request is None: 

80 return None 

81 use_line = str.strip(use_line[str.__len__(request):]) 

82 if (str.__len__(use_line) <= 0) or (use_line[0] != "{"): 

83 raise ValueError( 

84 f"rest line={use_line!r} for {request!r} in {line!r}.") 

85 

86 # find markers for search-replacing problematic chars 

87 use_line, esc = escape(use_line, __REPL.keys()) 

88 

89 # Now we collect all the arguments 

90 command: list[str | None] = [request] 

91 idx_0: int = 1 

92 while True: 

93 idx_1: int = use_line.find("}", idx_0) 

94 if idx_1 < idx_0: 

95 raise ValueError(f"Found {{ but no }} in {line!r}?") 

96 arg: str = str.strip(use_line[idx_0:idx_1]) 

97 if arg: 

98 for argi in str.split(arg): 

99 argj = str.strip(argi) 

100 if argj: 

101 argj = unescape(argj, esc) 

102 for k, v in __REPL.items(): 

103 argj = str.replace(argj, k, v) 

104 command.append(argj) 

105 else: 

106 command.append(None) 

107 idx_0 = use_line.find("{", idx_1 + 1) 

108 if idx_0 <= idx_1: 

109 break 

110 idx_0 += 1 

111 return command 

112 

113 

114#: the response header for the path 

115RESPONSE_PATH: Final[str] = "@texgit@path@" 

116#: the response header for the file name 

117RESPONSE_NAME: Final[str] = "@texgit@name@" 

118#: the response header for the escaped file name 

119RESPONSE_ESCAPED_NAME: Final[str] = "@texgit@escName@" 

120#: the response header for the url 

121RESPONSE_URL: Final[str] = "@texgit@url@" 

122 

123#: the command start A 

124__CMD_0A: Final[str] = r"\expandafter\xdef\csname " 

125#: the command start B 

126__CMD_0B: Final[str] = r"\expandafter\gdef\csname " 

127#: the command middle 

128__CMD_1: Final[str] = r"\endcsname{" 

129#: the command end 

130__CMD_2: Final[str] = r"}%" 

131 

132 

133def __make_response(prefix: str, name: str, value: str, 

134 xdef: bool = True) -> str: 

135 """ 

136 Make a response command. 

137 

138 :param prefix: the prefix 

139 :param value: the value 

140 :param xdef: do we do xdef? 

141 :return: the result 

142 

143 >>> print(__make_response(RESPONSE_PATH, 

144 ... "lst:test", "./git/12.txt").replace(chr(92), "x")) 

145 xexpandafterxxdefxcsname @texgit@path@lst:testxendcsname{./git/12.txt}% 

146 

147 >>> print(__make_response(RESPONSE_PATH, 

148 ... "lst:test", "./git/12.txt", False).replace(chr(92), "x")) 

149 xexpandafterxgdefxcsname @texgit@path@lst:testxendcsname{./git/12.txt}% 

150 """ 

151 return (f"{__CMD_0A if xdef else __CMD_0B}{str.strip(prefix)}" 

152 f"{str.strip(name)}{__CMD_1}{value}{str.strip(__CMD_2)}") 

153 

154 

155def __make_path_response(name: str, path: Path, base_dir: Path, 

156 basename: str | None = None)\ 

157 -> Generator[str, None, None]: 

158 r""" 

159 Make the path response commands. 

160 

161 :param name: the name prefix 

162 :param path: the file path 

163 :param basename: the basename 

164 :param base_dir: the base directory 

165 

166 >>> from pycommons.io.temp import temp_dir 

167 >>> with temp_dir() as td: 

168 ... list(__make_path_response("x", td.resolve_inside("yy"), td, None)) 

169 ['\\expandafter\\xdef\\csname @texgit@path@x\\endcsname{yy}%'] 

170 >>> with temp_dir() as td: 

171 ... v = list(__make_path_response("x", td.resolve_inside("yy"), td, 

172 ... "bla y_x")) 

173 >>> v[0] 

174 '\\expandafter\\xdef\\csname @texgit@path@x\\endcsname{yy}%' 

175 >>> v[1] 

176 '\\expandafter\\xdef\\csname @texgit@name@x\\endcsname{bla y_x}%' 

177 >>> v[2] 

178 '\\expandafter\\gdef\\csname @texgit@escName@x\\endcsname{bla~y\\_x}%' 

179 """ 

180 yield __make_response(RESPONSE_PATH, name, path.relative_to(base_dir)) 

181 if basename is not None: 

182 yield __make_response(RESPONSE_NAME, name, basename) 

183 yield __make_response( 

184 RESPONSE_ESCAPED_NAME, name, 

185 basename.replace("$", "\\$").replace("_", "\\_").replace( 

186 " ", "~"), False) # this one must be gdef! 

187 

188 

189def cmd_git_file(base_dir: Path, pm: ProcessManager, 

190 command: list[str | None]) -> Generator[str, None, None]: 

191 """ 

192 Get a file from `git`, maybe post-processed. 

193 

194 :param base_dir: the base directory 

195 :param pm: the process manager 

196 :param command: the command 

197 :return: the answer 

198 """ 

199 name: Final[str] = str.strip(command[1]) 

200 repo_url: Final[str] = str.strip(command[2]) 

201 relative_file: Final[str] = str.strip(command[3]) 

202 cmd: list[str] | None = command[4:] 

203 ll: Final[int] = list.__len__(cmd) 

204 if ll == 0 or ((ll == 1) and (cmd[0] is None)): 

205 cmd = None 

206 gp: Final[GitPath] = pm.get_git_file( 

207 repo_url, relative_file, name, cmd) 

208 yield from __make_path_response( 

209 name, gp.path, base_dir, gp.basename) 

210 if gp.url: 

211 yield __make_response(RESPONSE_URL, name, gp.url) 

212 

213 

214def cmd_arg_file(base_dir: Path, pm: ProcessManager, 

215 command: list[str | None]) -> Generator[str, None, None]: 

216 """ 

217 Get an argument file. 

218 

219 :param base_dir: the base directory 

220 :param pm: the process manager 

221 :param command: the command 

222 :return: the answer 

223 """ 

224 name: Final[str] = str.strip(command[1]) 

225 prefix: Final[str | None] = command[2] 

226 suffix: Final[str | None] = command[3] 

227 af: Final[Path] = pm.get_argument_file(name, prefix, suffix)[0] 

228 yield from __make_path_response(name, af, base_dir, af.basename()) 

229 

230 

231def cmd_exec(base_dir: Path, pm: ProcessManager, 

232 command: list[str | None]) -> Generator[str, None, None]: 

233 """ 

234 Execute a command and capture the output. 

235 

236 :param base_dir: the base directory 

237 :param pm: the process manager 

238 :param command: the command 

239 :return: the answer 

240 """ 

241 name: Final[str] = str.strip(command[1]) 

242 repo_url: Final[str | None] = command[2] 

243 relative_dir: Final[str | None] = command[3] 

244 cmd: list[str] = command[4:] 

245 yield from __make_path_response( 

246 name, pm.get_output(name, cmd, repo_url, relative_dir), base_dir) 

247 

248 

249def run(aux_arg: str, repo_dir_arg: str = "__git__") -> None: 

250 """ 

251 Execute the `texgit` tool. 

252 

253 This tool loads an LaTeX `aux` file, processes all file loading requests, 

254 and flushes the produced file paths back to the `aux` file. 

255 

256 :param aux_arg: the `aux` file argument 

257 :param repo_dir_arg: the repository directory argument 

258 """ 

259 aux_file: Path = Path(aux_arg) 

260 if not aux_file.is_file(): 

261 aux_file = Path(f"{aux_arg}.aux") 

262 if not aux_file.is_file(): 

263 raise ValueError(f"aux argument {aux_arg!r} does not identify a file " 

264 f"and neither does {aux_file!r}") 

265 logger(f"Using aux file {aux_file!r}.") 

266 

267 if getsize(aux_file) <= 0: 

268 logger(f"aux file {aux_file!r} is empty. Nothing to do. Exiting.") 

269 return 

270 lines: Final[list[str]] = list(aux_file.open_for_read()) 

271 lenlines: Final[int] = len(lines) 

272 if lenlines <= 0: 

273 logger(f"aux file {aux_file!r} contains no lines. " 

274 "Nothing to do. Exiting.") 

275 else: 

276 logger(f"Loaded {lenlines} lines from aux file {aux_file!r}.") 

277 

278 base_dir: Final[Path] = directory_path(dirname(aux_file)) 

279 logger(f"The base directory is {base_dir!r}.") 

280 

281 pm: ProcessManager | None = None 

282 append: list[str] = [] 

283 stripped_lines: list[str] = list(map(str.strip, lines)) 

284 deleted: int = 0 # the number of lines deleted 

285 

286 try: 

287 resolved: int = 0 

288 for idx, line in enumerate(stripped_lines): 

289 if line.startswith(FORBIDDEN_LINE): 

290 del lines[idx - deleted] 

291 deleted += 1 

292 

293 request: list[str | None] | None = __get_request(line) 

294 if request is None: 

295 continue 

296 

297 if pm is None: 

298 git_dir: Path = base_dir.resolve_inside(repo_dir_arg) 

299 logger(f"The repository directory is {git_dir!r}.") 

300 pm = ProcessManager(git_dir) 

301 

302 func = str.strip(request[0]) 

303 if func == REQUEST_GIT_FILE: 

304 append.extend(cmd_git_file(base_dir, pm, request)) 

305 elif func == REQUEST_ARG_FILE: 

306 append.extend(cmd_arg_file(base_dir, pm, request)) 

307 elif func == REQUEST_PROCESS: 

308 append.extend(cmd_exec(base_dir, pm, request)) 

309 else: 

310 raise ValueError(f"Invalid command {func} in line {line!r}.") 

311 resolved += 1 

312 finally: 

313 if pm is not None: 

314 pm.close() 

315 del pm 

316 

317 if (len(append) <= 0) and (deleted <= 0): 

318 logger("No file requests or deletion markers found. Nothing to do.") 

319 return 

320 

321 logger(f"Found and resolved {resolved} file requests.") 

322 for app in map(str.strip, append): # make the texgit invocation idempotent 

323 if app and (app not in stripped_lines): 

324 lines.append(app) 

325 with aux_file.open_for_write() as wd: 

326 write_lines(lines, wd) 

327 logger(f"Finished flushing {len(lines)} lines to aux file {aux_file!r}.") 

328 

329 

330# Execute the texgit tool 

331if __name__ == "__main__": 

332 parser: Final[argparse.ArgumentParser] = make_argparser( 

333 __file__, "Execute the texgit Tool.", 

334 make_epilog( 

335 "Download and provide local paths for " 

336 "files from git repositories and execute programs.", 

337 2023, 2025, "Thomas Weise", 

338 url="https://thomasweise.github.io/texgit_py", 

339 email="tweise@hfuu.edu.cn, tweise@ustc.edu.cn"), 

340 __version__) 

341 parser.add_argument( 

342 "aux", help="the aux file to process", type=str, default="") 

343 parser.add_argument( 

344 "--repoDir", help="the directory to use for caching output", 

345 type=str, default="__git__", nargs="?") 

346 args: Final[argparse.Namespace] = parser.parse_args() 

347 

348 run(args.aux.strip(), args.repoDir.strip()) 

349 logger("All done.")