codebase-receipts-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
  2. codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
  3. codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
  4. codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
  5. codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. receipts/__init__.py +0 -0
  7. receipts/ama/__init__.py +0 -0
  8. receipts/ama/interviewer.py +415 -0
  9. receipts/ats/__init__.py +1 -0
  10. receipts/ats/comparator.py +98 -0
  11. receipts/ats/scorer.py +550 -0
  12. receipts/ats/stats.py +164 -0
  13. receipts/cli.py +2062 -0
  14. receipts/config.py +106 -0
  15. receipts/errors.py +18 -0
  16. receipts/export.py +120 -0
  17. receipts/ingest/__init__.py +0 -0
  18. receipts/ingest/artifact_extractor.py +679 -0
  19. receipts/ingest/git_source.py +159 -0
  20. receipts/ingest/manifest.py +114 -0
  21. receipts/ingest/scanner.py +141 -0
  22. receipts/ingest/secrets_scanner.py +225 -0
  23. receipts/interactive/__init__.py +1 -0
  24. receipts/interactive/repl.py +755 -0
  25. receipts/ledger/__init__.py +0 -0
  26. receipts/ledger/pricing_table.py +54 -0
  27. receipts/ledger/token_ledger.py +226 -0
  28. receipts/llm/__init__.py +0 -0
  29. receipts/llm/anthropic_provider.py +95 -0
  30. receipts/llm/factory.py +124 -0
  31. receipts/llm/fake_provider.py +79 -0
  32. receipts/llm/gemini_provider.py +205 -0
  33. receipts/llm/json_utils.py +46 -0
  34. receipts/llm/metered.py +62 -0
  35. receipts/llm/ollama_provider.py +117 -0
  36. receipts/llm/openai_provider.py +118 -0
  37. receipts/llm/provider.py +42 -0
  38. receipts/llm/split.py +78 -0
  39. receipts/llm/token_estimate.py +35 -0
  40. receipts/mine/__init__.py +1 -0
  41. receipts/mine/code_metrics.py +246 -0
  42. receipts/mine/git_evidence.py +109 -0
  43. receipts/prep/__init__.py +1 -0
  44. receipts/prep/dossier.py +313 -0
  45. receipts/prep/readiness.py +227 -0
  46. receipts/resume/__init__.py +0 -0
  47. receipts/resume/claim_extractor.py +338 -0
  48. receipts/resume/loader.py +35 -0
  49. receipts/resume/pdf_parser.py +259 -0
  50. receipts/resume/tex_parser.py +394 -0
  51. receipts/rewrite/__init__.py +0 -0
  52. receipts/rewrite/compiler.py +180 -0
  53. receipts/rewrite/optimizer.py +140 -0
  54. receipts/rewrite/tex_rewriter.py +227 -0
  55. receipts/tui/__init__.py +0 -0
  56. receipts/tui/ama_app.py +454 -0
  57. receipts/tui/app.py +316 -0
  58. receipts/tui/dossier_app.py +170 -0
  59. receipts/tui/hub.py +367 -0
  60. receipts/tui/ingest_app.py +183 -0
  61. receipts/tui/rewrite_app.py +463 -0
  62. receipts/tui/score_app.py +237 -0
  63. receipts/tui/widgets/__init__.py +0 -0
  64. receipts/tui/widgets/claims_table.py +50 -0
  65. receipts/tui/widgets/diff_view.py +38 -0
  66. receipts/tui/widgets/status_bar.py +38 -0
  67. receipts/verify/__init__.py +0 -0
  68. receipts/verify/bm25.py +112 -0
  69. receipts/verify/enrich.py +128 -0
  70. receipts/verify/jd_fetch.py +101 -0
  71. receipts/verify/kb.py +379 -0
  72. receipts/verify/keyword_gap.py +127 -0
  73. receipts/verify/query_expand.py +88 -0
  74. receipts/verify/rerank.py +74 -0
  75. receipts/verify/rewriter.py +172 -0
  76. receipts/verify/router.py +211 -0
  77. receipts/verify/summaries.py +258 -0
  78. receipts/verify/verifier.py +552 -0
@@ -0,0 +1,394 @@
1
+ """Parse a LaTeX resume into structured sections, entries, and bullets.
2
+
3
+ Default configuration matches the ``twocolentry`` / ``onecolentry`` /
4
+ ``highlights`` template family (the "rendercv"-style layout). Override
5
+ ``TexParserConfig`` to handle other templates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ _TILDE_SENTINEL = "\x00TILDE\x00"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TexParserConfig:
19
+ """Environment names the parser looks for. Override for a different template."""
20
+
21
+ heading_env: str = "twocolentry"
22
+ content_env: str = "onecolentry"
23
+ bullet_env: str = "highlights"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ResumeBullet:
28
+ text: str
29
+ raw_tex: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ResumeEntry:
34
+ heading: str
35
+ date: str
36
+ bullets: list[ResumeBullet]
37
+ heading_raw: str = "" # raw TeX of the heading/content line, when known
38
+
39
+
40
+ @dataclass
41
+ class ParsedResume:
42
+ name: str
43
+ sections: list[ResumeSection] = field(default_factory=list)
44
+
45
+ @property
46
+ def all_bullets(self) -> list[tuple[str, str, ResumeBullet]]:
47
+ """(section_name, entry_heading, bullet) for every bullet."""
48
+ out: list[tuple[str, str, ResumeBullet]] = []
49
+ for sec in self.sections:
50
+ for entry in sec.entries:
51
+ for b in entry.bullets:
52
+ out.append((sec.name, entry.heading, b))
53
+ return out
54
+
55
+ @property
56
+ def all_items(self) -> list[tuple[str, str, ResumeBullet]]:
57
+ """Like ``all_bullets`` but also yields heading-only entries.
58
+
59
+ Entries without bullets (common in Skills, Certifications, Education)
60
+ produce a synthetic bullet from the heading text so the claim
61
+ extractor can process every line of the resume.
62
+ """
63
+ out: list[tuple[str, str, ResumeBullet]] = []
64
+ for sec in self.sections:
65
+ for entry in sec.entries:
66
+ if entry.bullets:
67
+ for b in entry.bullets:
68
+ out.append((sec.name, entry.heading, b))
69
+ elif entry.heading:
70
+ out.append(
71
+ (
72
+ sec.name,
73
+ entry.heading,
74
+ ResumeBullet(
75
+ text=entry.heading,
76
+ raw_tex=entry.heading_raw or entry.heading,
77
+ ),
78
+ )
79
+ )
80
+ return out
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ResumeSection:
85
+ name: str
86
+ entries: list[ResumeEntry]
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # LaTeX cleaning
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def clean_tex(text: str) -> str:
95
+ """Strip LaTeX formatting, returning readable plain text."""
96
+ s = text
97
+
98
+ # 1. Protect \(\sim\) → tilde (must happen before ~ → space)
99
+ s = s.replace("\\(\\sim\\)", _TILDE_SENTINEL)
100
+
101
+ # 2. Literal character escapes
102
+ s = s.replace("\\%", "%")
103
+ s = s.replace("\\_", "_")
104
+ s = s.replace("\\&", "&")
105
+ s = s.replace("\\$", "$")
106
+ s = s.replace("\\#", "#")
107
+ s = s.replace("\\textbar", "|")
108
+
109
+ # 3. Line breaks (\\, \\[3pt])
110
+ s = re.sub(r"\\\\(?:\[[^\]]*\])?", " ", s)
111
+
112
+ # 4. Spacing commands → space
113
+ s = re.sub(r"\\(?:enspace|quad|qquad|hfill|vfill)\b", " ", s)
114
+ s = re.sub(r"\\[,;!]", " ", s)
115
+ s = s.replace("~", " ")
116
+
117
+ # 5. Remove icon commands
118
+ s = re.sub(r"\\faIcon\{[^}]*\}", "", s)
119
+
120
+ # 6. Remove sizing / spacing with arguments
121
+ s = re.sub(r"\\(?:vspace|hspace)\{[^}]*\}", "", s)
122
+ s = re.sub(r"\\fontsize\{[^}]*\}\{[^}]*\}", "", s)
123
+ s = re.sub(r"\\selectfont", "", s)
124
+ s = re.sub(r"\\(?:par|kern)\\?[a-z]*", "", s)
125
+
126
+ # 7. \href{url}{text} → text (text arg may have 2 levels of nesting)
127
+ _NESTED2 = r"[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)?\}[^{}]*)*"
128
+ for _ in range(3):
129
+ prev = s
130
+ s = re.sub(
131
+ r"\\(?:href|hrefWithoutArrow)\{[^}]*\}\{(" + _NESTED2 + r")\}",
132
+ r"\1",
133
+ s,
134
+ )
135
+ if s == prev:
136
+ break
137
+
138
+ # 8. \textcolor{color}{text} → text (iterate for nesting)
139
+ for _ in range(5):
140
+ prev = s
141
+ s = re.sub(r"\\textcolor\{[^{}]*\}\{(" + _NESTED2 + r")\}", r"\1", s)
142
+ if s == prev:
143
+ break
144
+
145
+ # 9. \textbf / \textit / \texttt / … → text (iterate for nesting)
146
+ for _ in range(5):
147
+ prev = s
148
+ s = re.sub(
149
+ r"\\text(?:bf|it|tt|sc|rm|sf)\{(" + _NESTED2 + r")\}",
150
+ r"\1",
151
+ s,
152
+ )
153
+ if s == prev:
154
+ break
155
+
156
+ # 10. Font switches: {\small text}, {\bf text}
157
+ s = re.sub(
158
+ r"\{\\(?:small|bf|it|tt|sc|em|rm|sf|bfseries|itshape)\s+([^}]*)\}",
159
+ r"\1",
160
+ s,
161
+ )
162
+ s = re.sub(
163
+ r"\\(?:small|large|Large|LARGE|normalsize|footnotesize|tiny"
164
+ r"|scriptsize|bfseries|itshape|centering|raggedright|raggedleft)\b",
165
+ "",
166
+ s,
167
+ )
168
+ s = re.sub(r"\\linespread\{[^}]*\}", "", s)
169
+
170
+ # 11. Inline math: $…$ → content
171
+ s = re.sub(r"\$([^$]*)\$", r"\1", s)
172
+ s = re.sub(r"\\vcenter\{\\hbox\{([^}]*)\}\}", r"\1", s)
173
+
174
+ # 12. Strip remaining braces
175
+ s = s.replace("{", "").replace("}", "")
176
+
177
+ # 13. Restore tilde sentinel
178
+ s = s.replace(_TILDE_SENTINEL, "~")
179
+
180
+ # 14. En-dash cleanup: -- → –
181
+ s = s.replace("--", "–")
182
+
183
+ # 15. Collapse whitespace
184
+ s = re.sub(r"[ \t]+", " ", s)
185
+ s = s.strip()
186
+
187
+ return s
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Low-level helpers
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def _find_brace_group(text: str, start: int) -> tuple[str, int]:
196
+ """Return (content, end_index) for the brace group starting at *start*."""
197
+ if start >= len(text) or text[start] != "{":
198
+ return "", start
199
+ depth = 0
200
+ i = start
201
+ while i < len(text):
202
+ if text[i] == "{":
203
+ depth += 1
204
+ elif text[i] == "}":
205
+ depth -= 1
206
+ if depth == 0:
207
+ return text[start + 1 : i], i + 1
208
+ i += 1
209
+ return text[start + 1 :], len(text)
210
+
211
+
212
+ def _extract_items(highlights_text: str) -> list[ResumeBullet]:
213
+ """Split ``\\item`` entries from a highlights block."""
214
+ parts = re.split(r"\\item\s+", highlights_text)
215
+ bullets: list[ResumeBullet] = []
216
+ for raw in parts:
217
+ raw = raw.strip()
218
+ if not raw:
219
+ continue
220
+ cleaned = clean_tex(raw)
221
+ if cleaned:
222
+ bullets.append(ResumeBullet(text=cleaned, raw_tex=raw))
223
+ return bullets
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Section splitting
228
+ # ---------------------------------------------------------------------------
229
+
230
+ _SECTION_RE = re.compile(r"\\section\{", re.DOTALL)
231
+
232
+
233
+ def _split_sections(body: str) -> list[tuple[str, str]]:
234
+ """Return (section_name, section_body_text) pairs."""
235
+ hits = list(_SECTION_RE.finditer(body))
236
+ if not hits:
237
+ return []
238
+
239
+ sections: list[tuple[str, str]] = []
240
+ for idx, m in enumerate(hits):
241
+ name_raw, after = _find_brace_group(body, m.start() + len("\\section"))
242
+ name = clean_tex(name_raw)
243
+ end = hits[idx + 1].start() if idx + 1 < len(hits) else len(body)
244
+ sections.append((name, body[after:end]))
245
+ return sections
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Entry parsing within a section
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def _parse_section_content(text: str, cfg: TexParserConfig) -> list[ResumeEntry]:
254
+ entries: list[ResumeEntry] = []
255
+ pos = 0
256
+
257
+ h_tag = f"\\begin{{{cfg.heading_env}}}"
258
+ h_end_tag = f"\\end{{{cfg.heading_env}}}"
259
+ c_tag = f"\\begin{{{cfg.content_env}}}"
260
+ c_end_tag = f"\\end{{{cfg.content_env}}}"
261
+ b_tag = f"\\begin{{{cfg.bullet_env}}}"
262
+ b_end_tag = f"\\end{{{cfg.bullet_env}}}"
263
+
264
+ while pos < len(text):
265
+ h_pos = text.find(h_tag, pos)
266
+ c_pos = text.find(c_tag, pos)
267
+
268
+ if h_pos == -1 and c_pos == -1:
269
+ break
270
+
271
+ if h_pos != -1 and (c_pos == -1 or h_pos < c_pos):
272
+ # --- heading entry (twocolentry) ---
273
+ brace = text.find("{", h_pos + len(h_tag))
274
+ if brace == -1:
275
+ pos = h_pos + len(h_tag)
276
+ continue
277
+ date_raw, after_brace = _find_brace_group(text, brace)
278
+
279
+ end_marker = text.find(h_end_tag, after_brace)
280
+ if end_marker == -1:
281
+ pos = after_brace
282
+ continue
283
+ heading_raw = text[after_brace:end_marker].strip()
284
+ pos = end_marker + len(h_end_tag)
285
+
286
+ # look for a following content env (skip whitespace / vspace)
287
+ bullets: list[ResumeBullet] = []
288
+ rest = text[pos:]
289
+ stripped = re.sub(r"^[\s]*(?:\\vspace\{[^}]*\}[\s]*)*", "", rest)
290
+ offset = len(rest) - len(stripped)
291
+
292
+ if stripped.startswith(c_tag):
293
+ c_start = pos + offset
294
+ c_end = text.find(c_end_tag, c_start)
295
+ if c_end != -1:
296
+ inner = text[c_start + len(c_tag) : c_end]
297
+ hl_start = inner.find(b_tag)
298
+ if hl_start != -1:
299
+ hl_end = inner.find(b_end_tag, hl_start)
300
+ if hl_end != -1:
301
+ bullets = _extract_items(
302
+ inner[hl_start + len(b_tag) : hl_end]
303
+ )
304
+ else:
305
+ cleaned = clean_tex(inner.strip())
306
+ if cleaned:
307
+ bullets = [
308
+ ResumeBullet(text=cleaned, raw_tex=inner.strip())
309
+ ]
310
+ pos = c_end + len(c_end_tag)
311
+
312
+ entries.append(
313
+ ResumeEntry(
314
+ heading=clean_tex(heading_raw),
315
+ date=clean_tex(date_raw),
316
+ bullets=bullets,
317
+ )
318
+ )
319
+
320
+ else:
321
+ # --- standalone content env (skills, certs without heading) ---
322
+ c_end = text.find(c_end_tag, c_pos)
323
+ if c_end == -1:
324
+ pos = c_pos + len(c_tag)
325
+ continue
326
+ inner = text[c_pos + len(c_tag) : c_end]
327
+
328
+ hl_start = inner.find(b_tag)
329
+ if hl_start != -1:
330
+ hl_end = inner.find(b_end_tag, hl_start)
331
+ if hl_end != -1:
332
+ bullets = _extract_items(inner[hl_start + len(b_tag) : hl_end])
333
+ entries.append(ResumeEntry(heading="", date="", bullets=bullets))
334
+ else:
335
+ cleaned = clean_tex(inner.strip())
336
+ if cleaned:
337
+ entries.append(
338
+ ResumeEntry(
339
+ heading=cleaned,
340
+ date="",
341
+ bullets=[],
342
+ heading_raw=inner.strip(),
343
+ )
344
+ )
345
+
346
+ pos = c_end + len(c_end_tag)
347
+
348
+ return entries
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Name extraction
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ def _extract_name(body: str) -> str:
357
+ m = re.search(r"\\textbf\{\\textcolor\{[^}]*\}\{([^}]*)\}\}", body)
358
+ if m:
359
+ return m.group(1).strip()
360
+ header_m = re.search(r"\\begin\{header\}(.*?)\\end\{header\}", body, re.DOTALL)
361
+ if header_m:
362
+ bf = re.search(r"\\textbf\{([^}]*)\}", header_m.group(1))
363
+ if bf:
364
+ return clean_tex(bf.group(1))
365
+ return ""
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Public API
370
+ # ---------------------------------------------------------------------------
371
+
372
+
373
+ def parse_resume(
374
+ tex_source: str, config: TexParserConfig | None = None
375
+ ) -> ParsedResume:
376
+ """Parse a LaTeX resume source into a structured ``ParsedResume``."""
377
+ cfg = config or TexParserConfig()
378
+
379
+ doc = re.search(r"\\begin\{document\}(.*?)\\end\{document\}", tex_source, re.DOTALL)
380
+ body = doc.group(1) if doc else tex_source
381
+
382
+ name = _extract_name(body)
383
+ sections: list[ResumeSection] = []
384
+ for sec_name, sec_text in _split_sections(body):
385
+ entries = _parse_section_content(sec_text, cfg)
386
+ if entries:
387
+ sections.append(ResumeSection(name=sec_name, entries=entries))
388
+
389
+ return ParsedResume(name=name, sections=sections)
390
+
391
+
392
+ def parse_file(path: Path, config: TexParserConfig | None = None) -> ParsedResume:
393
+ """Read a ``.tex`` file and parse it."""
394
+ return parse_resume(path.read_text(encoding="utf-8"), config)
File without changes
@@ -0,0 +1,180 @@
1
+ """Compile a .tex file to PDF if a TeX toolchain is available."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CompileResult:
15
+ success: bool
16
+ pdf_path: Path | None
17
+ tool_used: str | None
18
+ error: str | None
19
+
20
+
21
+ # Well-known per-platform binary locations that may not be on PATH yet
22
+ # (e.g. MiKTeX installs to AppData on Windows; MacTeX to /Library).
23
+ _EXTRA_SEARCH_PATHS: list[str] = []
24
+
25
+ if sys.platform == "win32":
26
+ _local = os.environ.get("LOCALAPPDATA", "")
27
+ _program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
28
+ _EXTRA_SEARCH_PATHS = [
29
+ os.path.join(_local, "Programs", "MiKTeX", "miktex", "bin", "x64"),
30
+ os.path.join(_program_files, "MiKTeX", "miktex", "bin", "x64"),
31
+ os.path.join(_program_files, "MiKTeX 2.9", "miktex", "bin", "x64"),
32
+ ]
33
+ elif sys.platform == "darwin":
34
+ _EXTRA_SEARCH_PATHS = [
35
+ "/Library/TeX/texbin",
36
+ "/usr/local/texlive/2024/bin/universal-darwin",
37
+ "/usr/local/texlive/2023/bin/universal-darwin",
38
+ ]
39
+
40
+
41
+ def _which_extended(cmd: str) -> str | None:
42
+ """Like shutil.which but also searches known TeX install locations."""
43
+ found = shutil.which(cmd)
44
+ if found:
45
+ return found
46
+ exe = cmd if sys.platform != "win32" else cmd + ".exe"
47
+ for extra in _EXTRA_SEARCH_PATHS:
48
+ candidate = os.path.join(extra, exe)
49
+ if os.path.isfile(candidate):
50
+ return candidate
51
+ return None
52
+
53
+
54
+ def find_tex_compiler() -> str | None:
55
+ """Return the NAME of the first available TeX compiler, or None.
56
+
57
+ Preference order is platform-aware: on Windows latexmk needs Perl (rarely
58
+ installed), so pdflatex leads; on Unix latexmk leads. Searches PATH plus
59
+ known install locations (MiKTeX on Windows, MacTeX on macOS).
60
+ """
61
+ # On Windows latexmk requires Perl which is rarely installed; prefer
62
+ # pdflatex directly and fall back to latexmk only on Unix where Perl ships.
63
+ if sys.platform == "win32":
64
+ order = ("pdflatex", "xelatex", "lualatex", "latexmk", "tectonic")
65
+ else:
66
+ order = ("latexmk", "pdflatex", "xelatex", "lualatex", "tectonic")
67
+ for cmd in order:
68
+ if _which_extended(cmd):
69
+ return cmd
70
+ return None
71
+
72
+
73
+ def compile_tex(tex_path: Path, *, compiler: str | None = None) -> CompileResult:
74
+ """Compile a .tex file to PDF.
75
+
76
+ Returns a CompileResult with the PDF path on success, or a descriptive
77
+ error message on failure. Checks for the PDF by path (not exit code) so
78
+ MiKTeX's non-zero-on-warning behaviour doesn't cause false negatives.
79
+ """
80
+ if compiler is None:
81
+ compiler = find_tex_compiler()
82
+
83
+ if compiler is None:
84
+ return CompileResult(
85
+ success=False,
86
+ pdf_path=None,
87
+ tool_used=None,
88
+ error=(
89
+ "No TeX compiler found. Install one of:\n"
90
+ " • MiKTeX (Windows): https://miktex.org/download\n"
91
+ " • MacTeX (macOS): https://www.tug.org/mactex/\n"
92
+ " • TeX Live (Linux): sudo apt install texlive-full\n"
93
+ " • tectonic (any): https://tectonic-typesetting.github.io\n"
94
+ "Or paste your .tex into https://overleaf.com to compile online."
95
+ ),
96
+ )
97
+
98
+ # Resolve the compiler to a full path (may live off PATH, e.g. MiKTeX).
99
+ compiler_path = _which_extended(compiler)
100
+ if compiler_path is None:
101
+ return CompileResult(
102
+ success=False,
103
+ pdf_path=None,
104
+ tool_used=compiler,
105
+ error=f"{compiler} not found in PATH.",
106
+ )
107
+
108
+ tex_dir = tex_path.parent
109
+
110
+ if compiler == "latexmk":
111
+ cmd = [
112
+ compiler_path,
113
+ "-pdf",
114
+ "-interaction=nonstopmode",
115
+ "-output-directory=" + str(tex_dir),
116
+ str(tex_path),
117
+ ]
118
+ elif compiler == "tectonic":
119
+ cmd = [compiler_path, "--outdir", str(tex_dir), str(tex_path)]
120
+ else:
121
+ # pdflatex / xelatex / lualatex
122
+ cmd = [
123
+ compiler_path,
124
+ "-interaction=nonstopmode",
125
+ f"-output-directory={tex_dir}",
126
+ str(tex_path),
127
+ ]
128
+
129
+ try:
130
+ result = subprocess.run(
131
+ cmd,
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=120,
135
+ cwd=tex_dir,
136
+ )
137
+ except FileNotFoundError:
138
+ return CompileResult(
139
+ success=False,
140
+ pdf_path=None,
141
+ tool_used=compiler,
142
+ error=f"{compiler} disappeared from PATH mid-run.",
143
+ )
144
+ except subprocess.TimeoutExpired:
145
+ return CompileResult(
146
+ success=False,
147
+ pdf_path=None,
148
+ tool_used=compiler,
149
+ error=f"{compiler} timed out after 120 seconds.",
150
+ )
151
+
152
+ pdf_path = tex_path.with_suffix(".pdf")
153
+
154
+ # Check the file — MiKTeX exits non-zero even on success (update nag).
155
+ if pdf_path.exists():
156
+ return CompileResult(
157
+ success=True,
158
+ pdf_path=pdf_path,
159
+ tool_used=compiler,
160
+ error=None,
161
+ )
162
+
163
+ # Pull the most useful tail of output for the error message.
164
+ combined = (result.stderr or "") + (result.stdout or "")
165
+ # Strip the MiKTeX update nag — it's not an error.
166
+ lines = [
167
+ ln
168
+ for ln in combined.splitlines()
169
+ if "check for MiKTeX updates" not in ln and "major issue" not in ln
170
+ ]
171
+ error_text = "\n".join(lines[-20:]) or (
172
+ f"{compiler} exited with code {result.returncode}"
173
+ )
174
+
175
+ return CompileResult(
176
+ success=False,
177
+ pdf_path=None,
178
+ tool_used=compiler,
179
+ error=error_text,
180
+ )