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/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:])