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.
- cv_tools/__init__.py +7 -0
- cv_tools/__main__.py +5 -0
- cv_tools/api.py +123 -0
- cv_tools/bib.py +122 -0
- cv_tools/charset.py +142 -0
- cv_tools/cli.py +384 -0
- cv_tools/doctor.py +185 -0
- cv_tools/engine.py +320 -0
- cv_tools/loader.py +424 -0
- cv_tools/markup.py +614 -0
- cv_tools/migrate.py +222 -0
- cv_tools/model.py +192 -0
- cv_tools/render/__init__.py +1 -0
- cv_tools/render/html.py +581 -0
- cv_tools/render/latex.py +154 -0
- cv_tools/schema/cv-v2.schema.json +707 -0
- cv_tools/starter/cv.yaml +129 -0
- cv_tools/starter/publications.bib +55 -0
- cv_tools/templates/html/cv.css +252 -0
- cv_tools/templates/html/cv.html.jinja +19 -0
- cv_tools/templates/html/icons/github.svg +1 -0
- cv_tools/templates/html/icons/linkedin.svg +1 -0
- cv_tools/templates/html/icons/orcid.svg +1 -0
- cv_tools/templates/html/icons/twitter.svg +1 -0
- cv_tools/templates/html/macros.jinja +89 -0
- cv_tools/templates/latex/cv.tex.jinja +45 -0
- cv_tools/templates/latex/layouts/dated.jinja +12 -0
- cv_tools/templates/latex/layouts/entries-inline.jinja +23 -0
- cv_tools/templates/latex/layouts/entries.jinja +29 -0
- cv_tools/templates/latex/layouts/list.jinja +24 -0
- cv_tools/templates/latex/layouts/pairs.jinja +14 -0
- cv_tools/templates/latex/layouts/publications.jinja +14 -0
- cv_tools/templates/latex/layouts/text.jinja +10 -0
- cv_tools/templates/latex/orcid.pdf +0 -0
- cv_tools/templates/latex/packages.txt +47 -0
- cv_tools/templates/latex/resume.cls +265 -0
- cv_tools/templates/latex/unicode.txt +398 -0
- cv_tools/v1.py +484 -0
- cv_tools/validate.py +165 -0
- cv_tools/watch.py +76 -0
- cv_tools_cli-2.0.0.dist-info/METADATA +498 -0
- cv_tools_cli-2.0.0.dist-info/RECORD +46 -0
- cv_tools_cli-2.0.0.dist-info/WHEEL +5 -0
- cv_tools_cli-2.0.0.dist-info/entry_points.txt +2 -0
- cv_tools_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|