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/engine.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Compile rendered LaTeX to PDF with a local pdflatex or the cv-tools Docker image."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional, Union
|
|
14
|
+
|
|
15
|
+
from . import doctor
|
|
16
|
+
from .loader import CVError
|
|
17
|
+
from .render.latex import RenderResult
|
|
18
|
+
|
|
19
|
+
ENGINES = ("auto", "pdflatex", "docker")
|
|
20
|
+
JOBNAME = "cv"
|
|
21
|
+
TIMEOUT = 300 # seconds
|
|
22
|
+
DOCKER_TIMEOUT = 1800 # seconds; the first run also pulls the image
|
|
23
|
+
DOCKER_REPOSITORY = "ghcr.io/mlsdpk/cv-tools"
|
|
24
|
+
IMAGE_VARIABLE = "CV_TOOLS_IMAGE"
|
|
25
|
+
PDFLATEX_ARGS = ["-interaction=nonstopmode", "-halt-on-error", "-no-shell-escape", f"{JOBNAME}.tex"]
|
|
26
|
+
# The earliest date a zip entry holds, so the same input gives the same bytes.
|
|
27
|
+
ZIP_DATE = (1980, 1, 1, 0, 0, 0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# TikZ and scalerel, which cv-tools 1.x loaded for the ORCID logo.
|
|
31
|
+
REMOVED_COMMANDS = frozenset({
|
|
32
|
+
"tikz", "tikzset", "usetikzlibrary", "pgfmathsetmacro", "scalerel", "stretchrel", "scaleto", "scaleobj",
|
|
33
|
+
})
|
|
34
|
+
REMOVED_ENVIRONMENTS = frozenset({"tikzpicture"})
|
|
35
|
+
REMOVED_ADVICE = ("cv-tools 2.0 no longer loads TikZ and scalerel. "
|
|
36
|
+
"Remove it from the CV file or replace it with text.")
|
|
37
|
+
|
|
38
|
+
_COMPLETE_TEX: set[str] = set()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EngineError(CVError):
|
|
42
|
+
"""No engine can run, or the compilation failed."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_pdflatex() -> Optional[str]:
|
|
46
|
+
return shutil.which("pdflatex")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def docker_image() -> str:
|
|
50
|
+
"""The image for ``--engine docker``: ``CV_TOOLS_IMAGE``, or the one tagged with this release."""
|
|
51
|
+
from . import __version__
|
|
52
|
+
|
|
53
|
+
override = os.environ.get(IMAGE_VARIABLE)
|
|
54
|
+
if override:
|
|
55
|
+
return override
|
|
56
|
+
release = all(part.isdigit() for part in __version__.split("."))
|
|
57
|
+
return f"{DOCKER_REPOSITORY}:{__version__ if release else 'latest'}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_tex(result: RenderResult, tex_path: Path) -> list[Path]:
|
|
61
|
+
"""Write the .tex file (LF line ends) and its support files next to it."""
|
|
62
|
+
tex_path = Path(tex_path)
|
|
63
|
+
tex_path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
tex_path.write_bytes(result.tex.encode("utf-8"))
|
|
65
|
+
written = [tex_path]
|
|
66
|
+
for name, data in result.files.items():
|
|
67
|
+
target = tex_path.parent / name
|
|
68
|
+
target.write_bytes(data)
|
|
69
|
+
written.append(target)
|
|
70
|
+
return written
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_zip(result: RenderResult, zip_path: Path, tex_name: Optional[str] = None) -> Path:
|
|
74
|
+
"""Write the .tex and its support files into a zip that Overleaf can open."""
|
|
75
|
+
zip_path = Path(zip_path)
|
|
76
|
+
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
members = {tex_name or f"{zip_path.stem}.tex": result.tex.encode("utf-8"), **result.files}
|
|
78
|
+
with zipfile.ZipFile(zip_path, "w") as archive:
|
|
79
|
+
for name, data in members.items():
|
|
80
|
+
info = zipfile.ZipInfo(name, date_time=ZIP_DATE)
|
|
81
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
82
|
+
info.external_attr = 0o644 << 16
|
|
83
|
+
archive.writestr(info, data)
|
|
84
|
+
return zip_path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compile_env(source_date_epoch: Optional[int] = None) -> dict[str, str]:
|
|
88
|
+
"""The environment for pdflatex, with ``SOURCE_DATE_EPOCH`` set if given."""
|
|
89
|
+
env = dict(os.environ)
|
|
90
|
+
if source_date_epoch is not None:
|
|
91
|
+
env["SOURCE_DATE_EPOCH"] = str(int(source_date_epoch))
|
|
92
|
+
env["FORCE_SOURCE_DATE"] = "1"
|
|
93
|
+
return env
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def docker_problem(docker: doctor.DockerStatus) -> str:
|
|
97
|
+
if not docker.installed:
|
|
98
|
+
return "Docker is not installed"
|
|
99
|
+
return f"Docker is installed but not running ({docker.detail})"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def tlmgr_advice(missing: list[str]) -> str:
|
|
103
|
+
return f"'tlmgr install {' '.join(missing)}' (TeX Live)"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
MIKTEX_ADVICE = "with MiKTeX, which installs missing packages on the fly, build with --engine pdflatex"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def no_engine_message(tex: Optional[doctor.TexStatus], docker: doctor.DockerStatus) -> str:
|
|
110
|
+
"""Why ``--engine auto`` cannot build, and the ways to fix it."""
|
|
111
|
+
if tex is None or tex.pdflatex is None:
|
|
112
|
+
problem = "pdflatex was not found"
|
|
113
|
+
else:
|
|
114
|
+
problem = f"pdflatex is installed, but these TeX packages are missing: {' '.join(tex.missing)}"
|
|
115
|
+
lines = [f"cannot build the PDF. {problem}, and {docker_problem(docker)}.", "Choose one of these:"]
|
|
116
|
+
if tex is not None and tex.missing:
|
|
117
|
+
lines.append(f" - install the missing packages with {tlmgr_advice(tex.missing)}")
|
|
118
|
+
lines.append(f" - {MIKTEX_ADVICE}")
|
|
119
|
+
else:
|
|
120
|
+
lines.append(" - install TeX Live (https://tug.org/texlive/), then check it with 'cv-tools doctor'")
|
|
121
|
+
start = "start Docker" if docker.installed else "install Docker and start it"
|
|
122
|
+
lines += [
|
|
123
|
+
f" - {start}; cv-tools then compiles in the {DOCKER_REPOSITORY} image",
|
|
124
|
+
" - run 'cv-tools build --zip' and upload the zip to Overleaf,"
|
|
125
|
+
" or use --tex-only and compile the .tex elsewhere",
|
|
126
|
+
]
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _choose(
|
|
131
|
+
engine: str,
|
|
132
|
+
tex: Optional[doctor.TexStatus] = None,
|
|
133
|
+
docker: Optional[doctor.DockerStatus] = None,
|
|
134
|
+
) -> tuple[str, Optional[doctor.TexStatus], Optional[doctor.DockerStatus]]:
|
|
135
|
+
if engine not in ENGINES:
|
|
136
|
+
raise EngineError(f"unknown engine {engine!r} (expected one of: {', '.join(ENGINES)})")
|
|
137
|
+
if engine != "auto":
|
|
138
|
+
return engine, tex, docker
|
|
139
|
+
if tex is None:
|
|
140
|
+
pdflatex = find_pdflatex()
|
|
141
|
+
if pdflatex is not None and pdflatex in _COMPLETE_TEX:
|
|
142
|
+
return "pdflatex", None, docker
|
|
143
|
+
tex = doctor.check_tex(pdflatex)
|
|
144
|
+
if tex.ok:
|
|
145
|
+
if tex.checked:
|
|
146
|
+
_COMPLETE_TEX.add(tex.pdflatex)
|
|
147
|
+
return "pdflatex", tex, docker
|
|
148
|
+
if docker is None:
|
|
149
|
+
docker = doctor.check_docker()
|
|
150
|
+
if docker.running:
|
|
151
|
+
return "docker", tex, docker
|
|
152
|
+
raise EngineError(no_engine_message(tex, docker))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_engine(
|
|
156
|
+
engine: str = "auto",
|
|
157
|
+
tex: Optional[doctor.TexStatus] = None,
|
|
158
|
+
docker: Optional[doctor.DockerStatus] = None,
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Turn ``auto`` into ``pdflatex`` (when it has every package) or ``docker``."""
|
|
161
|
+
return _choose(engine, tex, docker)[0]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def fallback_notes(tex: Optional[doctor.TexStatus]) -> list[str]:
|
|
165
|
+
"""Why ``--engine auto`` compiles with Docker, and how to use the local TeX instead."""
|
|
166
|
+
if tex is None or tex.pdflatex is None:
|
|
167
|
+
return ["cv-tools: pdflatex was not found"]
|
|
168
|
+
return [
|
|
169
|
+
f"cv-tools: the local TeX installation lacks these packages: {' '.join(tex.missing)}",
|
|
170
|
+
f"cv-tools: to use it, install them with {tlmgr_advice(tex.missing)}; {MIKTEX_ADVICE}",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _csv_field(value: str) -> str:
|
|
175
|
+
# Docker and Podman read a --mount value as CSV.
|
|
176
|
+
if any(c in value for c in ',"\r\n'):
|
|
177
|
+
return '"' + value.replace('"', '""') + '"'
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def mount_option(directory: Path) -> str:
|
|
182
|
+
return f"type=bind,{_csv_field(f'source={Path(directory).resolve()}')},target=/work"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def container_name() -> str:
|
|
186
|
+
return f"cv-tools-{os.getpid()}-{secrets.token_hex(4)}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def docker_command(
|
|
190
|
+
directory: Path,
|
|
191
|
+
env: dict[str, str],
|
|
192
|
+
status: Optional[doctor.DockerStatus] = None,
|
|
193
|
+
name: Optional[str] = None,
|
|
194
|
+
) -> list[str]:
|
|
195
|
+
"""``docker run`` that compiles ``directory/cv.tex`` with the image's pdflatex."""
|
|
196
|
+
# label=disable lets SELinux hosts use the bind mount. Rootless Docker and
|
|
197
|
+
# Podman already run as the user, where --user would lose write access.
|
|
198
|
+
exe = (status.path if status is not None else None) or shutil.which("docker") or "docker"
|
|
199
|
+
cmd = [exe, "run", "--rm", "--name", name or container_name(), "--network", "none",
|
|
200
|
+
"--security-opt", "label=disable", "--mount", mount_option(directory), "-w", "/work"]
|
|
201
|
+
maps_user = status is not None and (status.rootless or status.podman)
|
|
202
|
+
if hasattr(os, "getuid") and not maps_user:
|
|
203
|
+
cmd += ["--user", f"{os.getuid()}:{os.getgid()}"]
|
|
204
|
+
for variable in ("SOURCE_DATE_EPOCH", "FORCE_SOURCE_DATE"):
|
|
205
|
+
if variable in env:
|
|
206
|
+
cmd += ["-e", variable]
|
|
207
|
+
return cmd + ["--entrypoint", "pdflatex", docker_image(), *PDFLATEX_ARGS]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _remove_container(exe: str, name: str) -> None:
|
|
211
|
+
try:
|
|
212
|
+
subprocess.run([exe, "rm", "-f", name], capture_output=True, timeout=doctor.TIMEOUT)
|
|
213
|
+
except (OSError, subprocess.SubprocessError):
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def compile_pdf(
|
|
218
|
+
result: RenderResult,
|
|
219
|
+
engine: str = "auto",
|
|
220
|
+
source_date_epoch: Optional[int] = None,
|
|
221
|
+
workdir: Union[str, Path, None] = None,
|
|
222
|
+
notify: Optional[Callable[[str], None]] = None,
|
|
223
|
+
) -> bytes:
|
|
224
|
+
"""Compile in a temporary directory and return the PDF bytes.
|
|
225
|
+
|
|
226
|
+
For Docker the directory is inside ``workdir``, because Docker Desktop,
|
|
227
|
+
Colima and Podman machines share only some host directories.
|
|
228
|
+
"""
|
|
229
|
+
notify = notify or (lambda line: None)
|
|
230
|
+
chosen, tex, docker = _choose(engine)
|
|
231
|
+
env = compile_env(source_date_epoch)
|
|
232
|
+
if chosen == "pdflatex":
|
|
233
|
+
exe = find_pdflatex()
|
|
234
|
+
if exe is None:
|
|
235
|
+
raise EngineError("pdflatex not found. Install TeX Live, or use --tex-only and compile the .tex elsewhere.")
|
|
236
|
+
with tempfile.TemporaryDirectory(prefix="cv-tools-", ignore_cleanup_errors=True) as tmp:
|
|
237
|
+
write_tex(result, Path(tmp) / f"{JOBNAME}.tex")
|
|
238
|
+
return _run([exe, *PDFLATEX_ARGS], Path(tmp), env, TIMEOUT, "pdflatex")
|
|
239
|
+
|
|
240
|
+
if docker is None:
|
|
241
|
+
docker = doctor.check_docker()
|
|
242
|
+
if not docker.running:
|
|
243
|
+
hint = "Start it" if docker.installed else "Install Docker"
|
|
244
|
+
raise EngineError(f"{docker_problem(docker)}. {hint}, or use --engine pdflatex.")
|
|
245
|
+
if engine == "auto":
|
|
246
|
+
for line in fallback_notes(tex):
|
|
247
|
+
notify(line)
|
|
248
|
+
image = docker_image()
|
|
249
|
+
if not doctor.has_image(docker.path, image):
|
|
250
|
+
notify(f"cv-tools: the first Docker build downloads {image} (a few hundred MB), "
|
|
251
|
+
"which can take a few minutes")
|
|
252
|
+
notify(f"compiling with Docker ({image})")
|
|
253
|
+
parent = Path(workdir) if workdir is not None else Path.cwd()
|
|
254
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
with tempfile.TemporaryDirectory(prefix=".cv-tools-", dir=parent, ignore_cleanup_errors=True) as tmp:
|
|
256
|
+
if docker.userns_remap:
|
|
257
|
+
# Container ids map to unprivileged host ids that are "others" here.
|
|
258
|
+
os.chmod(tmp, 0o777)
|
|
259
|
+
write_tex(result, Path(tmp) / f"{JOBNAME}.tex")
|
|
260
|
+
name = container_name()
|
|
261
|
+
cmd = docker_command(Path(tmp), env, docker, name)
|
|
262
|
+
return _run(cmd, Path(tmp), env, DOCKER_TIMEOUT, "pdflatex (in Docker)",
|
|
263
|
+
cleanup=lambda: _remove_container(cmd[0], name))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _run(cmd: list[str], tmp: Path, env: dict[str, str], timeout: int, label: str,
|
|
267
|
+
cleanup: Callable[[], None] = lambda: None) -> bytes:
|
|
268
|
+
try:
|
|
269
|
+
proc = subprocess.run(cmd, cwd=tmp, env=env, capture_output=True, timeout=timeout)
|
|
270
|
+
except subprocess.TimeoutExpired:
|
|
271
|
+
cleanup()
|
|
272
|
+
raise EngineError(f"{label} did not finish within {timeout} seconds") from None
|
|
273
|
+
except KeyboardInterrupt:
|
|
274
|
+
cleanup()
|
|
275
|
+
raise
|
|
276
|
+
except OSError as e:
|
|
277
|
+
raise EngineError(f"cannot run {cmd[0]}: {e.strerror or e}") from None
|
|
278
|
+
pdf = tmp / f"{JOBNAME}.pdf"
|
|
279
|
+
if proc.returncode == 0 and pdf.is_file() and pdf.stat().st_size > 0:
|
|
280
|
+
return pdf.read_bytes()
|
|
281
|
+
log = tmp / f"{JOBNAME}.log"
|
|
282
|
+
if log.exists():
|
|
283
|
+
text = log.read_text(encoding="utf-8", errors="replace")
|
|
284
|
+
else: # Docker reports its own errors on stderr
|
|
285
|
+
text = (proc.stdout + proc.stderr).decode(errors="replace")
|
|
286
|
+
if proc.returncode == 0:
|
|
287
|
+
message = f"{label} exited without writing {JOBNAME}.pdf"
|
|
288
|
+
details = log_errors(text).strip()
|
|
289
|
+
raise EngineError(message + (f":\n{details}" if details else ""))
|
|
290
|
+
removed = removed_command(text)
|
|
291
|
+
if removed is not None:
|
|
292
|
+
raise EngineError(f"{label} failed: {removed} is not available. {REMOVED_ADVICE}\n" + log_errors(text))
|
|
293
|
+
raise EngineError(f"{label} failed:\n" + log_errors(text))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def removed_command(log: str) -> Optional[str]:
|
|
297
|
+
"""The TikZ or scalerel command or environment that stopped pdflatex, if any."""
|
|
298
|
+
lines = log.splitlines()
|
|
299
|
+
for i, line in enumerate(lines):
|
|
300
|
+
if not line.startswith("!"):
|
|
301
|
+
continue
|
|
302
|
+
if line.startswith("! Undefined control sequence.") and i + 1 < len(lines):
|
|
303
|
+
found = re.search(r"\\([A-Za-z@]+)\s*$", lines[i + 1])
|
|
304
|
+
if found and found.group(1) in REMOVED_COMMANDS:
|
|
305
|
+
return "\\" + found.group(1)
|
|
306
|
+
found = re.match(r"! LaTeX Error: Environment (\S+) undefined\.", line)
|
|
307
|
+
if found and found.group(1) in REMOVED_ENVIRONMENTS:
|
|
308
|
+
return f"the {found.group(1)} environment"
|
|
309
|
+
return None
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def log_errors(log: str, context: int = 3) -> str:
|
|
314
|
+
"""The error lines of a TeX log and a little context."""
|
|
315
|
+
lines = log.splitlines()
|
|
316
|
+
out: list[str] = []
|
|
317
|
+
for i, line in enumerate(lines):
|
|
318
|
+
if line.startswith("!"):
|
|
319
|
+
out.extend(lines[i : i + 1 + context])
|
|
320
|
+
return "\n".join(out) if out else "\n".join(lines[-20:])
|