cv-tools-cli 2.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 (46) hide show
  1. cv_tools/__init__.py +7 -0
  2. cv_tools/__main__.py +5 -0
  3. cv_tools/api.py +123 -0
  4. cv_tools/bib.py +122 -0
  5. cv_tools/charset.py +142 -0
  6. cv_tools/cli.py +384 -0
  7. cv_tools/doctor.py +185 -0
  8. cv_tools/engine.py +320 -0
  9. cv_tools/loader.py +424 -0
  10. cv_tools/markup.py +614 -0
  11. cv_tools/migrate.py +222 -0
  12. cv_tools/model.py +192 -0
  13. cv_tools/render/__init__.py +1 -0
  14. cv_tools/render/html.py +581 -0
  15. cv_tools/render/latex.py +154 -0
  16. cv_tools/schema/cv-v2.schema.json +707 -0
  17. cv_tools/starter/cv.yaml +129 -0
  18. cv_tools/starter/publications.bib +55 -0
  19. cv_tools/templates/html/cv.css +252 -0
  20. cv_tools/templates/html/cv.html.jinja +19 -0
  21. cv_tools/templates/html/icons/github.svg +1 -0
  22. cv_tools/templates/html/icons/linkedin.svg +1 -0
  23. cv_tools/templates/html/icons/orcid.svg +1 -0
  24. cv_tools/templates/html/icons/twitter.svg +1 -0
  25. cv_tools/templates/html/macros.jinja +89 -0
  26. cv_tools/templates/latex/cv.tex.jinja +45 -0
  27. cv_tools/templates/latex/layouts/dated.jinja +12 -0
  28. cv_tools/templates/latex/layouts/entries-inline.jinja +23 -0
  29. cv_tools/templates/latex/layouts/entries.jinja +29 -0
  30. cv_tools/templates/latex/layouts/list.jinja +24 -0
  31. cv_tools/templates/latex/layouts/pairs.jinja +14 -0
  32. cv_tools/templates/latex/layouts/publications.jinja +14 -0
  33. cv_tools/templates/latex/layouts/text.jinja +10 -0
  34. cv_tools/templates/latex/orcid.pdf +0 -0
  35. cv_tools/templates/latex/packages.txt +47 -0
  36. cv_tools/templates/latex/resume.cls +265 -0
  37. cv_tools/templates/latex/unicode.txt +398 -0
  38. cv_tools/v1.py +484 -0
  39. cv_tools/validate.py +165 -0
  40. cv_tools/watch.py +76 -0
  41. cv_tools_cli-2.0.0.dist-info/METADATA +498 -0
  42. cv_tools_cli-2.0.0.dist-info/RECORD +46 -0
  43. cv_tools_cli-2.0.0.dist-info/WHEEL +5 -0
  44. cv_tools_cli-2.0.0.dist-info/entry_points.txt +2 -0
  45. cv_tools_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
  46. cv_tools_cli-2.0.0.dist-info/top_level.txt +1 -0
cv_tools/cli.py ADDED
@@ -0,0 +1,384 @@
1
+ """Command line interface: ``cv-tools build | watch | init | migrate | validate | doctor | schema``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import os
9
+ import platform
10
+ import re
11
+ import sys
12
+ from importlib import resources
13
+ from pathlib import Path
14
+ from typing import Optional, Sequence
15
+
16
+ import yaml
17
+
18
+ from . import __version__, api, doctor, engine, watch
19
+ from .loader import CVError, resolves_to_string
20
+ from .migrate import migrate_text
21
+ from .model import Document
22
+ from .validate import ValidationError
23
+
24
+ EXIT_OK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2
25
+ STARTER_FILES = ("cv.yaml", "publications.bib")
26
+ OUTPUT_SUFFIXES = (".pdf", ".tex", ".zip")
27
+ INTERVAL_RANGE = (0.05, 60.0) # seconds
28
+
29
+
30
+ def _print_error(error: CVError) -> None:
31
+ errors = error.errors if isinstance(error, ValidationError) else [error]
32
+ for e in errors:
33
+ print(e.format("error"), file=sys.stderr)
34
+
35
+
36
+ def _print_os_error(error: OSError) -> None:
37
+ reason = error.strerror or str(error)
38
+ location = error.filename if error.filename is not None else None
39
+ print(f"{location}: error: {reason}" if location is not None else f"error: {reason}", file=sys.stderr)
40
+
41
+
42
+ def _note(line: str) -> None:
43
+ print(line, file=sys.stderr, flush=True)
44
+
45
+
46
+ def _print_warnings(warnings: list[str]) -> None:
47
+ for w in warnings:
48
+ print(w, file=sys.stderr)
49
+
50
+
51
+ def _load_for_build(config: Path) -> Document:
52
+ """Load a CV and print its warnings, with the v1 conversion warnings as one summary line."""
53
+ doc, v1_warnings = api.load_with_v1_warnings(config)
54
+ if v1_warnings:
55
+ n = len(v1_warnings)
56
+ what = "1 v1 field or item was" if n == 1 else f"{n} v1 fields or items were"
57
+ print(f"cv-tools: {what} ignored, as v1 ignored them; run 'cv-tools validate {config}' for details",
58
+ file=sys.stderr)
59
+ _print_warnings(doc.warnings[len(v1_warnings):])
60
+ return doc
61
+
62
+
63
+ def output_path(output: Optional[str], config: Path, kind: str) -> Path:
64
+ """Where ``build`` writes a ``kind`` (.pdf, .tex or .zip) file."""
65
+ # A name without one of these suffixes gets ``kind`` appended, so J.Doe-CV keeps its dot.
66
+ default = Path(config).stem + kind
67
+ if output is None:
68
+ return Path(default)
69
+ out = Path(output)
70
+ if output.endswith(("/", os.sep)) or out.is_dir():
71
+ return out / default
72
+ suffix = out.suffix.lower()
73
+ if suffix == ".zip" and kind != ".zip":
74
+ raise CVError("a .zip output needs --zip, which writes the LaTeX sources for Overleaf", output)
75
+ if suffix not in OUTPUT_SUFFIXES:
76
+ return out.with_name(out.name + kind)
77
+ return out
78
+
79
+
80
+ def _with_suffix(path: Path, suffix: str) -> Path:
81
+ return path if path.suffix.lower() == suffix else path.with_suffix(suffix)
82
+
83
+
84
+ def _write_outputs(args: argparse.Namespace, doc: Document) -> None:
85
+ config = Path(args.config)
86
+ result = api.render_tex(doc)
87
+ kind = ".zip" if args.zip else ".tex" if args.tex_only else ".pdf"
88
+ out = output_path(args.output, config, kind)
89
+
90
+ if args.zip:
91
+ path = engine.write_zip(result, _with_suffix(out, ".zip"))
92
+ print(f"wrote {path}", file=sys.stderr)
93
+ return
94
+ # A .tex output also keeps the LaTeX sources next to the PDF, as run.sh did.
95
+ if args.tex_only or out.suffix.lower() == ".tex":
96
+ for path in engine.write_tex(result, _with_suffix(out, ".tex")):
97
+ print(f"wrote {path}", file=sys.stderr)
98
+ if args.tex_only:
99
+ return
100
+ pdf_path = _with_suffix(out, ".pdf")
101
+ pdf = engine.compile_pdf(result, engine=args.engine, workdir=pdf_path.parent, notify=_note)
102
+ pdf_path.parent.mkdir(parents=True, exist_ok=True)
103
+ pdf_path.write_bytes(pdf)
104
+ print(f"wrote {pdf_path}", file=sys.stderr)
105
+
106
+
107
+ def cmd_build(args: argparse.Namespace) -> int:
108
+ doc = _load_for_build(Path(args.config))
109
+ _write_outputs(args, doc)
110
+ return EXIT_OK
111
+
112
+
113
+ def cmd_watch(args: argparse.Namespace) -> int:
114
+ config = Path(args.config)
115
+ last = {config.resolve()}
116
+
117
+ def build_once() -> set[Path]:
118
+ nonlocal last
119
+ try:
120
+ doc = _load_for_build(config)
121
+ except CVError as e:
122
+ _print_error(e)
123
+ return last | {config.resolve()}
124
+ except OSError as e:
125
+ _print_os_error(e)
126
+ return last | {config.resolve()}
127
+ last = watch.watched_files(doc, config)
128
+ try:
129
+ _write_outputs(args, doc)
130
+ except CVError as e:
131
+ _print_error(e)
132
+ except OSError as e: # for example an output directory that is not writable
133
+ _print_os_error(e)
134
+ return last
135
+
136
+ try:
137
+ watch.run(build_once, args.interval)
138
+ except KeyboardInterrupt:
139
+ print("stopped", file=sys.stderr)
140
+ return EXIT_OK
141
+
142
+
143
+ def cmd_init(args: argparse.Namespace) -> int:
144
+ target = Path(args.directory)
145
+ for name in STARTER_FILES:
146
+ if (target / name).exists():
147
+ raise CVError("already exists; 'cv-tools init' never overwrites files", str(target / name))
148
+ target.mkdir(parents=True, exist_ok=True)
149
+ for name in STARTER_FILES:
150
+ data = (resources.files("cv_tools") / "starter" / name).read_bytes()
151
+ with open(target / name, "xb") as f: # "x" also refuses a file created meanwhile
152
+ f.write(data)
153
+ print(f"wrote {target / name}", file=sys.stderr)
154
+ print(f"next: edit {target / 'cv.yaml'}, then run 'cv-tools build {target / 'cv.yaml'}'", file=sys.stderr)
155
+ return EXIT_OK
156
+
157
+
158
+ def _same_file(a: Path, b: Path) -> bool:
159
+ try:
160
+ return a.samefile(b)
161
+ except OSError:
162
+ return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
163
+
164
+
165
+ _PLAIN_PATH = re.compile(r"[A-Za-z0-9_.][A-Za-z0-9_./+-]*") # safe as a plain YAML scalar
166
+
167
+
168
+ def rebase_bib_paths(text: str, source_dir: Path, target_dir: Path) -> str:
169
+ """Rewrite each relative ``bib:`` of a v2 file so it names the same file from ``target_dir``."""
170
+ source_dir, target_dir = Path(os.path.abspath(source_dir)), Path(os.path.abspath(target_dir))
171
+ if source_dir == target_dir:
172
+ return text
173
+ root = yaml.compose(text)
174
+ sections = next((v for k, v in getattr(root, "value", []) if k.value == "sections"), None)
175
+ edits = []
176
+ for section in getattr(sections, "value", None) or []:
177
+ if not isinstance(section, yaml.MappingNode):
178
+ continue
179
+ for key, value in section.value:
180
+ if key.value != "bib" or not isinstance(value, yaml.ScalarNode) or os.path.isabs(value.value):
181
+ continue
182
+ target = os.path.normpath(source_dir / value.value)
183
+ try:
184
+ shared = os.path.commonpath([target, target_dir])
185
+ except ValueError: # another drive on Windows
186
+ shared = None
187
+ if shared is None or shared == Path(target).anchor:
188
+ new = Path(target).as_posix() # nothing in common but the root: an absolute path is clearer
189
+ else:
190
+ new = Path(os.path.relpath(target, target_dir)).as_posix()
191
+ if new != value.value:
192
+ # A plain scalar only when it reads back as the same string ("2024" would be a number).
193
+ plain = _PLAIN_PATH.fullmatch(new) and resolves_to_string(new)
194
+ quoted = new if plain else json.dumps(new, ensure_ascii=False)
195
+ edits.append((value.start_mark.index, value.end_mark.index, quoted))
196
+ for start, end, replacement in sorted(edits, reverse=True):
197
+ text = text[:start] + replacement + text[end:]
198
+ return text
199
+
200
+
201
+ def migrate_output(output: Optional[str], source: Path) -> Optional[Path]:
202
+ if output is None:
203
+ return None
204
+ out = Path(output)
205
+ if output.endswith(("/", os.sep)) or out.is_dir():
206
+ return out / source.name
207
+ return out
208
+
209
+
210
+ def cmd_migrate(args: argparse.Namespace) -> int:
211
+ source = Path(args.file)
212
+ output = migrate_output(args.output, source)
213
+ # Messages name the output as written, unless it is a directory.
214
+ shown = args.output if output is not None and Path(args.output) == output else str(output)
215
+ if output is not None:
216
+ if _same_file(source, output):
217
+ raise CVError("the output is the input file; write the v2 file elsewhere and check it "
218
+ "before replacing the v1 file", shown)
219
+ if output.exists() and not args.force:
220
+ raise CVError("already exists; use --force to overwrite it", shown)
221
+ try:
222
+ text = source.read_text(encoding="utf-8")
223
+ except OSError as e:
224
+ raise CVError(f"cannot read file: {e.strerror}", str(source)) from None
225
+ migration = migrate_text(text, str(source))
226
+ _print_warnings(migration.warnings)
227
+ if output is not None:
228
+ output.parent.mkdir(parents=True, exist_ok=True)
229
+ # bib: paths are relative to the real directories, after symlinks, as the loader reads them.
230
+ out_text = rebase_bib_paths(migration.text, source.resolve().parent, output.parent.resolve())
231
+ output.write_bytes(out_text.encode("utf-8"))
232
+ print(f"wrote {shown}", file=sys.stderr)
233
+ else:
234
+ sys.stdout.write(migration.text)
235
+ return EXIT_OK
236
+
237
+
238
+ def cmd_validate(args: argparse.Namespace) -> int:
239
+ status = EXIT_OK
240
+ for name in args.files:
241
+ try:
242
+ doc = api.load(name)
243
+ except CVError as e:
244
+ _print_error(e)
245
+ status = EXIT_ERROR
246
+ continue
247
+ _print_warnings(doc.warnings)
248
+ print(f"{name}: valid ({doc.source_format})")
249
+ return status
250
+
251
+
252
+ def cmd_doctor(args: argparse.Namespace) -> int:
253
+ if args.tlmgr:
254
+ print(doctor.tlmgr_command())
255
+ return EXIT_OK
256
+ pdflatex = engine.find_pdflatex()
257
+ tex = doctor.check_tex(pdflatex)
258
+ docker = doctor.check_docker()
259
+ rows = [("cv-tools", __version__), ("python", f"{platform.python_version()} ({sys.executable})")]
260
+ year = None
261
+ if pdflatex is None:
262
+ rows.append(("pdflatex", "not found"))
263
+ else:
264
+ distribution = doctor.tex_distribution(pdflatex)
265
+ year = doctor.texlive_year(distribution)
266
+ rows.append(("pdflatex", f"{pdflatex} ({distribution or 'unknown version'})"))
267
+ total = len(doctor.required_packages())
268
+ if not tex.checked:
269
+ rows.append(("packages", "not checked (kpsewhich not found)"))
270
+ elif tex.missing:
271
+ rows.append(("packages", f"{len(tex.missing)} of {total} missing: {' '.join(tex.missing)}"))
272
+ else:
273
+ rows.append(("packages", f"all {total} found"))
274
+ rows.append(("docker", f"{docker.path} ({docker.detail})" if docker.path else "not found"))
275
+ for key, value in rows:
276
+ print(f"{key:<10}{value}")
277
+ print()
278
+ try:
279
+ chosen = engine.resolve_engine("auto", tex, docker)
280
+ except engine.EngineError as e:
281
+ print(f"not ready: {e.message}")
282
+ return EXIT_ERROR
283
+ if chosen == "pdflatex":
284
+ print("ready: 'cv-tools build' uses the local pdflatex")
285
+ if year is not None and year != doctor.TESTED_TEXLIVE:
286
+ print(f"note: cv-tools is tested with TeX Live {doctor.TESTED_TEXLIVE}; "
287
+ f"with TeX Live {year} the PDF can differ slightly")
288
+ else:
289
+ print(f"ready: 'cv-tools build' uses Docker ({engine.docker_image()})")
290
+ return EXIT_OK
291
+
292
+
293
+ def cmd_schema(args: argparse.Namespace) -> int:
294
+ path = resources.files("cv_tools") / "schema" / "cv-v2.schema.json"
295
+ if args.print:
296
+ sys.stdout.write(path.read_text(encoding="utf-8"))
297
+ else:
298
+ print(path)
299
+ return EXIT_OK
300
+
301
+
302
+ def interval(text: str) -> float:
303
+ try:
304
+ value = float(text)
305
+ except ValueError:
306
+ raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
307
+ low, high = INTERVAL_RANGE
308
+ if not (math.isfinite(value) and low <= value <= high):
309
+ raise argparse.ArgumentTypeError(f"must be between {low:g} and {high:g} seconds, not {text}")
310
+ return value
311
+
312
+
313
+ def build_parser() -> argparse.ArgumentParser:
314
+ parser = argparse.ArgumentParser(prog="cv-tools", description="Generate a LaTeX CV from a YAML file.")
315
+ parser.add_argument("--version", action="version", version=f"cv-tools {__version__}")
316
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
317
+
318
+ outputs = argparse.ArgumentParser(add_help=False)
319
+ outputs.add_argument("config", help="the CV YAML file (v1 or v2)")
320
+ outputs.add_argument(
321
+ "-o", "--output",
322
+ help="output file or directory (default: <config name>.pdf in the current directory). "
323
+ "A name without .pdf, .tex or .zip gets the right suffix appended. "
324
+ "A .tex path also writes the LaTeX sources next to the PDF.",
325
+ )
326
+ only = outputs.add_mutually_exclusive_group()
327
+ only.add_argument("--tex-only", action="store_true", help="write the .tex, resume.cls and orcid.pdf; skip pdflatex")
328
+ only.add_argument("--zip", action="store_true", help="write a .zip of the LaTeX sources for Overleaf; skip pdflatex")
329
+ outputs.add_argument(
330
+ "--engine", choices=engine.ENGINES, default="auto",
331
+ help="auto (default) uses the local pdflatex if it has every package, else Docker",
332
+ )
333
+
334
+ build = sub.add_parser("build", parents=[outputs], help="build the PDF (or the .tex or .zip) from a CV file")
335
+ build.set_defaults(func=cmd_build)
336
+
337
+ watch_ = sub.add_parser("watch", parents=[outputs], help="build, then rebuild whenever the CV or its .bib files change")
338
+ watch_.add_argument("--interval", type=interval, default=0.5, metavar="SECONDS",
339
+ help="time between checks for changes, from 0.05 to 60 (default: 0.5)")
340
+ watch_.set_defaults(func=cmd_watch)
341
+
342
+ init = sub.add_parser("init", help="write a starter cv.yaml and publications.bib")
343
+ init.add_argument("directory", nargs="?", default=".", help="where to write them (default: the current directory)")
344
+ init.set_defaults(func=cmd_init)
345
+
346
+ migrate = sub.add_parser("migrate", help="convert a v1 CV file to the v2 format")
347
+ migrate.add_argument("file", help="the v1 YAML file")
348
+ migrate.add_argument(
349
+ "-o", "--output",
350
+ help="write the v2 file here instead of standard output (a directory gets the input file's name); "
351
+ "relative bib: paths are adjusted to its directory",
352
+ )
353
+ migrate.add_argument("--force", action="store_true", help="overwrite the -o file if it exists")
354
+ migrate.set_defaults(func=cmd_migrate)
355
+
356
+ validate = sub.add_parser("validate", help="check CV files and report errors with line numbers")
357
+ validate.add_argument("files", nargs="+", metavar="FILE")
358
+ validate.set_defaults(func=cmd_validate)
359
+
360
+ doctor_ = sub.add_parser("doctor", help="check that pdflatex, the TeX packages or Docker are ready")
361
+ doctor_.add_argument("--tlmgr", action="store_true",
362
+ help="only print the 'tlmgr install' command for every TeX package cv-tools needs")
363
+ doctor_.set_defaults(func=cmd_doctor)
364
+
365
+ schema = sub.add_parser("schema", help="print the path of the v2 JSON Schema")
366
+ schema.add_argument("--print", action="store_true", help="print the schema itself")
367
+ schema.set_defaults(func=cmd_schema)
368
+ return parser
369
+
370
+
371
+ def main(argv: Optional[Sequence[str]] = None) -> int:
372
+ args = build_parser().parse_args(argv)
373
+ try:
374
+ return args.func(args)
375
+ except CVError as e:
376
+ _print_error(e)
377
+ return EXIT_ERROR
378
+ except OSError as e: # an output that cannot be written, a full disk
379
+ _print_os_error(e)
380
+ return EXIT_ERROR
381
+
382
+
383
+ if __name__ == "__main__":
384
+ sys.exit(main())
cv_tools/doctor.py ADDED
@@ -0,0 +1,185 @@
1
+ """Offline checks for a PDF build: pdflatex with the TeX packages, or Docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from dataclasses import dataclass, field
12
+ from importlib import resources
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ TIMEOUT = 30 # seconds, for each external command
17
+
18
+ # The file that shows a package is installed, when it is not "<package>.sty".
19
+ PROBES = {
20
+ "cm": "cmr10.tfm",
21
+ "cm-super": "type1ec.sty",
22
+ "ec": "ecrm1000.tfm",
23
+ "epstopdf-pkg": "epstopdf-base.sty",
24
+ "fontname": "texfonts.map",
25
+ "graphics-cfg": "graphics.cfg",
26
+ "graphics-def": "pdftex.def",
27
+ "kpathsea": "texmf.cnf",
28
+ "l3kernel": "l3backend-pdftex.def",
29
+ "latex": "latex.ltx",
30
+ "latexconfig": "epstopdf-sys.cfg",
31
+ "mptopdf": "supp-pdf.mkii",
32
+ "tools": "array.sty",
33
+ }
34
+
35
+ TESTED_TEXLIVE = 2026
36
+
37
+
38
+ def probe_file(package: str) -> str:
39
+ return PROBES.get(package, f"{package}.sty")
40
+
41
+
42
+ def required_packages() -> list[str]:
43
+ text = (resources.files("cv_tools") / "templates" / "latex" / "packages.txt").read_text(encoding="utf-8")
44
+ names = (line.split("#", 1)[0].strip() for line in text.splitlines())
45
+ return [name for name in names if name]
46
+
47
+
48
+ def tlmgr_command() -> str:
49
+ """The ``tlmgr install`` command for every package cv-tools needs."""
50
+ return "tlmgr install " + " ".join(["latex-bin", *required_packages()])
51
+
52
+
53
+ @dataclass
54
+ class TexStatus:
55
+ pdflatex: Optional[str]
56
+ missing: list[str] = field(default_factory=list)
57
+ checked: bool = True # False without kpsewhich
58
+
59
+ @property
60
+ def ok(self) -> bool:
61
+ return self.pdflatex is not None and not self.missing
62
+
63
+
64
+ @dataclass
65
+ class DockerStatus:
66
+ path: Optional[str]
67
+ running: bool
68
+ detail: str # the server version, or why Docker cannot be used
69
+ rootless: bool = False
70
+ podman: bool = False
71
+ userns_remap: bool = False
72
+
73
+ @property
74
+ def installed(self) -> bool:
75
+ return self.path is not None
76
+
77
+
78
+ def find_kpsewhich(pdflatex: str) -> Optional[str]:
79
+ """The kpsewhich next to ``pdflatex`` (or its symlink target), else the one on PATH."""
80
+ name = "kpsewhich.exe" if sys.platform == "win32" else "kpsewhich"
81
+ path = Path(pdflatex)
82
+ for directory in (path.resolve().parent, path.parent):
83
+ if (directory / name).is_file():
84
+ return str(directory / name)
85
+ return shutil.which("kpsewhich")
86
+
87
+
88
+ def missing_packages(kpsewhich: str, packages: list[str]) -> list[str]:
89
+ """The packages whose probe file kpsewhich cannot find."""
90
+ # In an empty directory, since kpsewhich also searches the current one.
91
+ probes = {probe_file(p): p for p in packages}
92
+ with tempfile.TemporaryDirectory(prefix="cv-tools-") as empty:
93
+ proc = subprocess.run(
94
+ [kpsewhich, "-progname=pdflatex", *probes], capture_output=True, text=True, timeout=TIMEOUT, cwd=empty
95
+ )
96
+ found = {Path(line.strip()).name for line in proc.stdout.splitlines() if line.strip()}
97
+ return [package for probe, package in probes.items() if probe not in found]
98
+
99
+
100
+ def check_tex(pdflatex: Optional[str]) -> TexStatus:
101
+ if pdflatex is None:
102
+ return TexStatus(None)
103
+ kpsewhich = find_kpsewhich(pdflatex)
104
+ if kpsewhich is None:
105
+ return TexStatus(pdflatex, checked=False)
106
+ try:
107
+ return TexStatus(pdflatex, missing_packages(kpsewhich, required_packages()))
108
+ except (OSError, subprocess.SubprocessError):
109
+ return TexStatus(pdflatex, checked=False)
110
+
111
+
112
+ def tex_distribution(pdflatex: str) -> Optional[str]:
113
+ """For example "TeX Live 2026"."""
114
+ try:
115
+ proc = subprocess.run([pdflatex, "--version"], capture_output=True, text=True, timeout=TIMEOUT)
116
+ except (OSError, subprocess.SubprocessError):
117
+ return None
118
+ first = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
119
+ match = re.search(r"TeX Live (\d{4})", first)
120
+ return f"TeX Live {match.group(1)}" if match else (first or None)
121
+
122
+
123
+ def texlive_year(distribution: Optional[str]) -> Optional[int]:
124
+ match = re.fullmatch(r"TeX Live (\d{4})", distribution or "")
125
+ return int(match.group(1)) if match else None
126
+
127
+
128
+ def check_docker() -> DockerStatus:
129
+ """Whether ``docker`` answers, and whether it is rootless Docker or Podman."""
130
+ exe = shutil.which("docker")
131
+ if exe is None:
132
+ return DockerStatus(None, False, "not found")
133
+ try:
134
+ proc = subprocess.run(
135
+ [exe, "info", "--format", "{{json .}}"], capture_output=True, text=True, timeout=TIMEOUT
136
+ )
137
+ except subprocess.TimeoutExpired:
138
+ return DockerStatus(exe, False, f"the daemon did not answer within {TIMEOUT} seconds")
139
+ except OSError as e:
140
+ return DockerStatus(exe, False, e.strerror or str(e))
141
+ return parse_docker_info(exe, proc.returncode, proc.stdout, proc.stderr)
142
+
143
+
144
+ def _json_object(text: str) -> dict:
145
+ for candidate in (text, *reversed(text.strip().splitlines())):
146
+ try:
147
+ data = json.loads(candidate)
148
+ except ValueError:
149
+ continue
150
+ if isinstance(data, dict):
151
+ return data
152
+ return {}
153
+
154
+
155
+ def parse_docker_info(exe: str, returncode: int, stdout: str, stderr: str) -> DockerStatus:
156
+ data = _json_object(stdout)
157
+ errors = [str(e).strip() for e in data.get("ServerErrors") or [] if str(e).strip()]
158
+ lines = [line.strip() for line in stderr.splitlines() if line.strip()]
159
+ host, version_info = data.get("host"), data.get("version")
160
+ if isinstance(host, dict) and isinstance(version_info, dict): # Podman's own report
161
+ version = str(version_info.get("Version") or "")
162
+ security = host.get("security") if isinstance(host.get("security"), dict) else {}
163
+ rootless = bool(security.get("rootless"))
164
+ detail = f"Podman {version}" + (", rootless" if rootless else "")
165
+ if returncode == 0 and version:
166
+ return DockerStatus(exe, True, detail, rootless=rootless, podman=True)
167
+ else:
168
+ version = str(data.get("ServerVersion") or "")
169
+ options = [str(o) for o in data.get("SecurityOptions") or []]
170
+ names = {part[5:] for o in options for part in o.split(",") if part.startswith("name=")}
171
+ rootless = "rootless" in names
172
+ detail = f"server {version}" + (", rootless" if rootless else "")
173
+ if returncode == 0 and version and not errors:
174
+ return DockerStatus(exe, True, detail, rootless=rootless, userns_remap="userns" in names)
175
+ reason = (errors or lines or ["the daemon is not running"])[-1]
176
+ return DockerStatus(exe, False, reason)
177
+
178
+
179
+ def has_image(docker: str, image: str) -> bool:
180
+ """Whether ``image`` is already present."""
181
+ try:
182
+ proc = subprocess.run([docker, "image", "inspect", image], capture_output=True, timeout=TIMEOUT)
183
+ except (OSError, subprocess.SubprocessError):
184
+ return False
185
+ return proc.returncode == 0