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/v1.py ADDED
@@ -0,0 +1,484 @@
1
+ """v1 detection and the v1 to v2 conversion, used for rendering and by ``cv-tools migrate``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable
8
+
9
+ from .loader import Source, YamlPath, describe
10
+ from .markup import Latex, escape_url, to_latex, v1_decode
11
+ from .model import ContactItem
12
+ from .render import latex
13
+
14
+ DEPRECATION = (
15
+ "cv-tools: {file} uses the v1 format, which is deprecated and will be removed "
16
+ "in a future release. Run 'cv-tools migrate {file}' to convert it."
17
+ )
18
+
19
+ CV_DATE_ITEM = "Curriculum Vitae, {date}"
20
+ # v1 printed the address separator (\\ inside \address, "$|$\ ") before the
21
+ # date item only; "| " prints the same.
22
+ CV_DATE_AFTER_SEPARATOR = "| " + CV_DATE_ITEM
23
+
24
+ _SOCIALS = {"orcid_id": "orcid", "linkedin": "linkedin", "github": "github", "twitter": "twitter"}
25
+ _ICONS = set(_SOCIALS.values()) # attach to the previous item with a space
26
+
27
+ _KNOWN_KEYS = {
28
+ "document": {"version", "heading", "subheading", "sections"},
29
+ "heading": {"name"},
30
+ "subheading-group": {"type", "content"},
31
+ "links": {"name", "highlight", "show_below"},
32
+ "socials": {"type", "url", "show_below"},
33
+ "education": {"type", "content"},
34
+ "education-entry": {
35
+ "university", "location", "dates", "degree", "honors", "thesis_title", "supervisor", "bullets",
36
+ },
37
+ "experience": {"type", "content"},
38
+ "experience-group": {"name", "entity"},
39
+ "experience-entry": {
40
+ "organization", "dates", "position", "supervisor", "department", "location", "responsibilities",
41
+ },
42
+ "bullets": {"type", "title", "description", "content"},
43
+ "bullets-entry": {"item", "subitems"},
44
+ "skills": {"type", "content"},
45
+ "skills-entry": {"name", "data"},
46
+ "talks": {"type", "title", "content"},
47
+ "talks-entry": {"name", "month", "year"},
48
+ "publications": {"type", "title", "bibfile"},
49
+ "newpage": {"type"},
50
+ }
51
+
52
+
53
+ def version_of(value: Any) -> int | None:
54
+ """1 or 2 for a numeric ``version`` value that names a format, else None."""
55
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
56
+ return None
57
+ return int(value) if value in (1, 2) else None
58
+
59
+
60
+ def detect(src: Source) -> str:
61
+ """Return ``"v1"`` or ``"v2"``, or raise :class:`CVError`."""
62
+ data = src.data
63
+ if not isinstance(data, dict):
64
+ raise src.error("expected a mapping at the top level of the CV file")
65
+ if "version" in data:
66
+ value = data["version"]
67
+ version = version_of(value)
68
+ if version is not None:
69
+ return f"v{version}"
70
+ if isinstance(value, str) and version_of(_number(value)) is not None:
71
+ raise src.error(f"unsupported version {value!r}: the version is a number; "
72
+ f"write 'version: {value.strip()}' without quotes", ("version",))
73
+ raise src.error(f"unsupported version {value!r}: expected 'version: 2' "
74
+ "(or 'version: 1' for the deprecated v1 format)", ("version",))
75
+ sections = data.get("sections")
76
+ typed = isinstance(sections, list) and any(isinstance(s, dict) and "type" in s for s in sections)
77
+ if "heading" in data or "subheading" in data or typed:
78
+ return "v1"
79
+ raise src.error(
80
+ "cannot tell the CV format: expected 'version: 2' at the top of a v2 file "
81
+ "(v1 files have 'heading', 'subheading' or sections with 'type')"
82
+ )
83
+
84
+
85
+ def _number(text: str) -> float | None:
86
+ try:
87
+ return float(text)
88
+ except ValueError:
89
+ return None
90
+
91
+
92
+ def unescape(text: str) -> str:
93
+ """Turn the simple v1 escapes (``\\% \\& \\# \\_ \\$``) into plain characters."""
94
+ return re.sub(r"\\([%&#_$])", r"\1", text)
95
+
96
+
97
+ @dataclass
98
+ class Conversion:
99
+ data: dict
100
+ warnings: list[str] = field(default_factory=list)
101
+ # v2 path -> v1 path, to carry comments over.
102
+ origins: dict[YamlPath, YamlPath] = field(default_factory=dict)
103
+
104
+
105
+ def convert(src: Source, clean: bool = False) -> Conversion:
106
+ return _Converter(src, clean).run()
107
+
108
+
109
+ class _Converter:
110
+ def __init__(self, src: Source, clean: bool):
111
+ self.src = src
112
+ self.clean = clean
113
+ self.out = Conversion({})
114
+ self.warnings: list[tuple[tuple[int, int], str]] = []
115
+ self.highlight: list[str] = [] # v1 bolds authors equal to the raw heading name
116
+
117
+ def warn(self, path: YamlPath, message: str, key: bool = False) -> None:
118
+ error = self.src.error(message, path, key)
119
+ self.warnings.append(((error.line or 0, error.col or 0), error.format("warning")))
120
+
121
+ def origin(self, v2: YamlPath, v1: YamlPath) -> None:
122
+ self.out.origins[v2] = v1
123
+
124
+ def mapping(self, value: Any, path: YamlPath, kind: str) -> dict:
125
+ if not isinstance(value, dict):
126
+ raise self.src.error(f"expected a mapping, got {describe(value)}", path)
127
+ for key in value:
128
+ if key not in _KNOWN_KEYS[kind]:
129
+ self.warn(path + (key,), f"v1 ignores the field '{key}'; it is dropped", key=True)
130
+ return value
131
+
132
+ def items(self, value: Any, path: YamlPath) -> list[tuple[int, Any]]:
133
+ # Missing and null mean empty, as in v1's Jinja loops.
134
+ if value is None:
135
+ return []
136
+ if not isinstance(value, list):
137
+ raise self.src.error(f"expected a list, got {describe(value)}", path)
138
+ out = []
139
+ for i, item in enumerate(value):
140
+ if item is None:
141
+ self.warn(path + (i,), "empty list item (null); it is dropped")
142
+ else:
143
+ out.append((i, item))
144
+ return out
145
+
146
+ def decode(self, value: Any, path: YamlPath) -> str:
147
+ if isinstance(value, str):
148
+ return v1_decode(value)
149
+ if isinstance(value, (int, float)): # includes bool, printed as True/False by Jinja
150
+ return str(value)
151
+ raise self.src.error(f"expected text, got {describe(value)}", path)
152
+
153
+ def text(
154
+ self,
155
+ value: Any,
156
+ path: YamlPath,
157
+ wrap: tuple[str, str] = ("", ""),
158
+ latex_wrap: tuple[str, str] = ("", ""),
159
+ key: Callable[[Any], Any] = to_latex,
160
+ ) -> Any:
161
+ latex = latex_wrap[0] + self.decode(value, path) + latex_wrap[1]
162
+ if self.clean:
163
+ if isinstance(value, str):
164
+ candidate: Any = wrap[0] + unescape(value) + wrap[1]
165
+ elif wrap == ("", "") and not isinstance(value, bool):
166
+ candidate = value
167
+ else:
168
+ candidate = wrap[0] + str(value) + wrap[1]
169
+ if key(candidate) == key(Latex(latex)):
170
+ return candidate
171
+ return {"latex": latex}
172
+
173
+ def raw(self, latex: str, candidate: Any, key: Callable[[Any], Any] = to_latex) -> Any:
174
+ if self.clean and key(candidate) == key(Latex(latex)):
175
+ return candidate
176
+ return {"latex": latex}
177
+
178
+ def url(self, value: Any, path: YamlPath) -> Any:
179
+ latex = self.decode(value, path) if value is not None else ""
180
+ candidate = value if isinstance(value, str) else latex
181
+ if self.clean and escape_url(candidate) == latex:
182
+ return candidate
183
+ return {"latex": latex}
184
+
185
+ def run(self) -> Conversion:
186
+ data = self.mapping(self.src.data, (), "document")
187
+ doc = self.out.data
188
+ doc["version"] = 2
189
+
190
+ heading = data.get("heading")
191
+ raw_name = None
192
+ if heading is not None:
193
+ raw_name = self.mapping(heading, ("heading",), "heading").get("name")
194
+ doc["name"] = self.text(raw_name, ("heading", "name")) if raw_name is not None else ""
195
+ self.origin(("name",), ("heading", "name"))
196
+ if isinstance(raw_name, str) and raw_name:
197
+ self.highlight = [raw_name] # compared before decoding, as v1 did
198
+
199
+ if data.get("subheading"):
200
+ doc["contact"] = self.contact(data["subheading"])
201
+ self.origin(("contact",), ("subheading",))
202
+
203
+ sections: list = []
204
+ for i, section in self.items(data.get("sections"), ("sections",)):
205
+ sections.extend(self.section(section, ("sections", i), len(sections)))
206
+ doc["sections"] = sections
207
+ self.origin(("sections",), ("sections",))
208
+ self.out.warnings = [text for _, text in sorted(self.warnings, key=lambda w: w[0])]
209
+ return self.out
210
+
211
+ def contact(self, subheading: Any) -> list:
212
+ lines: dict[bool, list[tuple[str, Any, str]]] = {False: [], True: []}
213
+ for g, group in self.items(subheading, ("subheading",)):
214
+ gpath = ("subheading", g)
215
+ group = self.mapping(group, gpath, "subheading-group")
216
+ gtype = group.get("type")
217
+ for e, entity in self.items(group.get("content"), gpath + ("content",)):
218
+ epath = gpath + ("content", e)
219
+ if gtype not in ("links", "socials"):
220
+ self.warn(epath, f"v1 does not print subheading groups of type {gtype!r}; the item is dropped")
221
+ continue
222
+ entity = self.mapping(entity, epath, gtype)
223
+ below = entity.get("show_below")
224
+ if not isinstance(below, bool):
225
+ self.warn(
226
+ epath,
227
+ "v1 does not print subheading items without show_below: true or false; the item is dropped",
228
+ )
229
+ continue
230
+ item = self.contact_item(gtype, entity, epath)
231
+ if item is not None:
232
+ lines[below].append(item)
233
+
234
+ line1 = [{kind: value} if kind != "text" else value for kind, value, _ in lines[False]]
235
+ if any(kind not in _ICONS for kind, _, _ in lines[False][1:]):
236
+ line1 = {"items": line1, "separator": " "} # v1 joins a line with spaces
237
+
238
+ below = lines[True]
239
+ line2: Any = [{kind: value} if kind != "text" else value for kind, value, _ in below]
240
+ if any(kind not in _ICONS for kind, _, _ in below[1:]):
241
+ # v1 joins these items with spaces and puts the address separator
242
+ # only before the date item.
243
+ line2 = {"items": line2 + [CV_DATE_AFTER_SEPARATOR], "separator": " "}
244
+ else:
245
+ # Without items before it, v1 starts the date line with a space,
246
+ # which moves the centred line by half a space.
247
+ line2.append(CV_DATE_ITEM if below else " " + CV_DATE_ITEM)
248
+ return [line1, line2]
249
+
250
+ def contact_item(self, gtype: str, entity: dict, path: YamlPath) -> tuple[str, Any, str] | None:
251
+ if gtype == "links":
252
+ name = entity.get("name")
253
+ if entity.get("highlight") is True:
254
+ kind, value = "url", self.url(name, path + ("name",))
255
+ elif name is None:
256
+ kind, value = "text", ""
257
+ else:
258
+ kind, value = "text", self.text(name, path + ("name",), key=_contact_key)
259
+ if isinstance(value, str) and "\n" in value:
260
+ value = {"latex": self.decode(name, path + ("name",))} # v2 contact text is single-line
261
+ else:
262
+ social = entity.get("type")
263
+ if social not in _SOCIALS:
264
+ self.warn(path + ("type",), f"v1 does not print socials of type {social!r}; the item is dropped")
265
+ return None
266
+ kind, value = _SOCIALS[social], self.url(entity.get("url"), path + ("url",))
267
+ model_value = Latex(value["latex"]) if isinstance(value, dict) else value
268
+ return (kind, value, latex.contact_item(ContactItem(kind, model_value), latex.AUTO_DATE))
269
+
270
+ def section(self, section: Any, path: YamlPath, index: int) -> list:
271
+ if not isinstance(section, dict):
272
+ raise self.src.error(f"expected a section mapping, got {describe(section)}", path)
273
+ stype = section.get("type")
274
+ if stype not in _SECTION_TYPES:
275
+ self.warn(path, f"v1 does not print sections of type {stype!r}; the section is dropped")
276
+ return []
277
+ self.mapping(section, path, stype)
278
+ out = getattr(self, "section_" + stype)(section, path, index)
279
+ if out and isinstance(out[0], dict):
280
+ self.origin(("sections", index, "layout"), path + ("type",))
281
+ return out
282
+
283
+ def title(self, section: dict, path: YamlPath, v2: YamlPath) -> Any:
284
+ self.origin(v2 + ("title",), path + ("title",))
285
+ value = section.get("title")
286
+ return self.text(value, path + ("title",)) if value is not None else ""
287
+
288
+ def entries(self, section: dict, path: YamlPath, kind: str):
289
+ for n, (i, entry) in enumerate(self.items(section.get("content"), path + ("content",))):
290
+ epath = path + ("content", i)
291
+ yield n, epath, self.mapping(entry, epath, kind)
292
+
293
+ def field(self, entry: dict, name: str, path: YamlPath, out: dict, v2name: str, v2: YamlPath, **kw) -> None:
294
+ if entry.get(name) is not None:
295
+ out[v2name] = self.text(entry[name], path + (name,), **kw)
296
+ self.origin(v2 + (v2name,), path + (name,))
297
+
298
+ def bullets(self, value: Any, path: YamlPath) -> list:
299
+ return [self.text(b, path + (i,)) for i, b in self.items(value, path)]
300
+
301
+ def section_education(self, section: dict, path: YamlPath, index: int) -> list:
302
+ v2 = ("sections", index)
303
+ self.origin(v2, path)
304
+ entries = []
305
+ for i, epath, edu in self.entries(section, path, "education-entry"):
306
+ e2 = v2 + ("entries", i)
307
+ self.origin(e2, epath)
308
+ entry = {"title": ""}
309
+ self.field(edu, "university", epath, entry, "title", e2)
310
+ # v1 always prints ", location", even when the location is missing.
311
+ entry["location"] = ""
312
+ self.field(edu, "location", epath, entry, "location", e2)
313
+ self.field(edu, "dates", epath, entry, "date", e2)
314
+ self.field(edu, "degree", epath, entry, "subtitle", e2)
315
+ self.field(edu, "honors", epath, entry, "note", e2)
316
+ details = []
317
+ if edu.get("thesis_title"):
318
+ details.append(
319
+ self.text(
320
+ edu["thesis_title"], epath + ("thesis_title",),
321
+ wrap=("Thesis Title: *", "*"), latex_wrap=("Thesis Title: \\textit{", "}"),
322
+ )
323
+ )
324
+ if edu.get("supervisor"):
325
+ details.append(
326
+ self.text(
327
+ edu["supervisor"], epath + ("supervisor",),
328
+ wrap=("Supervisor: ", ""), latex_wrap=("Supervisor: ", ""),
329
+ )
330
+ )
331
+ if details:
332
+ entry["details"] = details
333
+ if edu.get("bullets"):
334
+ entry["bullets"] = self.bullets(edu["bullets"], epath + ("bullets",))
335
+ self.origin(e2 + ("bullets",), epath + ("bullets",))
336
+ entries.append(entry)
337
+ return [{"title": "Education", "layout": "entries-inline", "entries": entries}]
338
+
339
+ def section_experience(self, section: dict, path: YamlPath, index: int) -> list:
340
+ out = []
341
+ for g, gpath, group in self.entries(section, path, "experience-group"):
342
+ v2 = ("sections", index + g)
343
+ self.origin(v2, gpath)
344
+ title = self.text(group["name"], gpath + ("name",)) if group.get("name") is not None else ""
345
+ self.origin(v2 + ("title",), gpath + ("name",))
346
+ entries = []
347
+ for n, (i, entity) in enumerate(self.items(group.get("entity"), gpath + ("entity",))):
348
+ epath = gpath + ("entity", i)
349
+ e2 = v2 + ("entries", n)
350
+ self.origin(e2, epath)
351
+ exp = self.mapping(entity, epath, "experience-entry")
352
+ entry = {"title": ""}
353
+ self.field(exp, "organization", epath, entry, "title", e2)
354
+ self.field(exp, "dates", epath, entry, "date", e2)
355
+ self.field(exp, "position", epath, entry, "subtitle", e2)
356
+ if exp.get("supervisor"):
357
+ entry["note"] = self.text(
358
+ exp["supervisor"], epath + ("supervisor",),
359
+ wrap=("Supervisor: ", ""), latex_wrap=("Supervisor: ", ""),
360
+ )
361
+ self.origin(e2 + ("note",), epath + ("supervisor",))
362
+ # v1 prints "department\\location" as the fifth rSubsection argument.
363
+ details = []
364
+ if exp.get("department"):
365
+ details.append(self.text(exp["department"], epath + ("department",)))
366
+ location = exp.get("location")
367
+ if location is not None and location != "":
368
+ details.append(self.text(location, epath + ("location",)))
369
+ elif details:
370
+ details.append("") # v1 leaves a trailing line break
371
+ if details:
372
+ entry["details"] = details
373
+ if exp.get("responsibilities"):
374
+ entry["bullets"] = self.bullets(exp["responsibilities"], epath + ("responsibilities",))
375
+ self.origin(e2 + ("bullets",), epath + ("responsibilities",))
376
+ entries.append(entry)
377
+ out.append({"title": title, "layout": "entries", "entries": entries})
378
+ return out
379
+
380
+ def section_bullets(self, section: dict, path: YamlPath, index: int) -> list:
381
+ v2 = ("sections", index)
382
+ self.origin(v2, path)
383
+ out: dict = {"title": self.title(section, path, v2), "layout": "list"}
384
+ if section.get("description"):
385
+ out["description"] = self.text(section["description"], path + ("description",))
386
+ self.origin(v2 + ("description",), path + ("description",))
387
+ entries = []
388
+ for i, epath, item in self.entries(section, path, "bullets-entry"):
389
+ self.origin(v2 + ("entries", i), epath)
390
+ text = self.text(item["item"], epath + ("item",)) if item.get("item") is not None else ""
391
+ if item.get("subitems"):
392
+ entries.append({"text": text, "bullets": self.bullets(item["subitems"], epath + ("subitems",))})
393
+ self.origin(v2 + ("entries", i, "bullets"), epath + ("subitems",))
394
+ else:
395
+ entries.append(text)
396
+ out["entries"] = entries
397
+ return [out]
398
+
399
+ def section_skills(self, section: dict, path: YamlPath, index: int) -> list:
400
+ v2 = ("sections", index)
401
+ self.origin(v2, path)
402
+ entries = []
403
+ for i, epath, skill in self.entries(section, path, "skills-entry"):
404
+ e2 = v2 + ("entries", i)
405
+ self.origin(e2, epath)
406
+ entry = {"title": "", "text": ""}
407
+ self.field(skill, "name", epath, entry, "title", e2)
408
+ self.field(skill, "data", epath, entry, "text", e2)
409
+ entries.append(entry)
410
+ return [{"title": "Skills", "layout": "pairs", "entries": entries}]
411
+
412
+ def section_talks(self, section: dict, path: YamlPath, index: int) -> list:
413
+ v2 = ("sections", index)
414
+ self.origin(v2, path)
415
+ out = {"title": self.title(section, path, v2), "layout": "dated"}
416
+ entries = []
417
+ for i, epath, talk in self.entries(section, path, "talks-entry"):
418
+ e2 = v2 + ("entries", i)
419
+ self.origin(e2, epath)
420
+ entry: dict = {}
421
+ date = self.talk_date(talk, epath)
422
+ if date is not None:
423
+ entry["date"] = date
424
+ self.origin(e2 + ("date",), epath + ("month",))
425
+ entry["text"] = ""
426
+ # v2 prints a line break in a dated cell as \newline, where v1 wrote \\.
427
+ self.field(talk, "name", epath, entry, "text", e2, key=latex.dated_text)
428
+ entries.append(entry)
429
+ out["entries"] = entries
430
+ return [out]
431
+
432
+ def talk_date(self, talk: dict, path: YamlPath) -> Any:
433
+ month, year = talk.get("month"), talk.get("year")
434
+ if month is None and year is None:
435
+ return None
436
+ m = self.decode(month, path + ("month",)) if month is not None else ""
437
+ y = self.decode(year, path + ("year",)) if year is not None else ""
438
+ latex = f"{m} {y}"
439
+ if _dated_date(Latex(latex)) != (m, y):
440
+ latex = f"{m} {{{y}}}" # keep the year in one column; braces print nothing
441
+ if month is None:
442
+ candidate = unescape(year) if isinstance(year, str) else str(year) if isinstance(year, bool) else year
443
+ else:
444
+ parts = [unescape(v) if isinstance(v, str) else str(v) for v in (month, "" if year is None else year)]
445
+ candidate = " ".join(parts)
446
+ return self.raw(latex, candidate, key=_dated_date)
447
+
448
+ def section_publications(self, section: dict, path: YamlPath, index: int) -> list:
449
+ v2 = ("sections", index)
450
+ self.origin(v2, path)
451
+ bibfile = section.get("bibfile")
452
+ if not isinstance(bibfile, str):
453
+ raise self.src.error(
454
+ f"section '{section.get('title', 'Untitled')}' is missing a 'bibfile' entry", path
455
+ )
456
+ out = {"title": self.title(section, path, v2), "layout": "publications", "bib": bibfile}
457
+ self.origin(v2 + ("bib",), path + ("bibfile",))
458
+ name = self.out.data["name"]
459
+ default = [name["latex"] if isinstance(name, dict) else str(name)]
460
+ if not self.clean or _names(default) != _names(self.highlight):
461
+ out["highlight"] = list(self.highlight)
462
+ return [out]
463
+
464
+ def section_newpage(self, section: dict, path: YamlPath, index: int) -> list:
465
+ self.origin(("sections", index), path)
466
+ return ["newpage"]
467
+
468
+
469
+ _SECTION_TYPES = {"education", "experience", "bullets", "skills", "talks", "publications", "newpage"}
470
+
471
+
472
+ def _dated_date(value: Any) -> tuple[str, str]:
473
+ # talk_date has a local variable named latex.
474
+ return latex.dated_date(value)
475
+
476
+
477
+ def _names(names: list[str]) -> list[str]:
478
+ return [n for n in names if n]
479
+
480
+
481
+ def _contact_key(value: Any) -> str:
482
+ # A contact text item replaces {date}; any marker works for the comparison.
483
+ return to_latex(value, date="\x00date\x00")
484
+
cv_tools/validate.py ADDED
@@ -0,0 +1,165 @@
1
+ """JSON Schema validation of v2 files with line-numbered, friendly errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from importlib import resources
11
+
12
+ import jsonschema
13
+
14
+ from .loader import CVError, Source, YamlPath, describe
15
+
16
+ # Slips that difflib would not suggest; the first valid one wins.
17
+ _ALIASES = {
18
+ "content": ("entries", "bullets"),
19
+ "items": ("entries", "bullets"),
20
+ "entity": ("entries",),
21
+ "subitems": ("bullets", "entries"),
22
+ "responsibilities": ("bullets", "entries"),
23
+ "dates": ("date",),
24
+ "month": ("date",),
25
+ "year": ("date",),
26
+ "heading": ("name",),
27
+ "subheading": ("contact",),
28
+ "type": ("layout",),
29
+ "bibfile": ("bib",),
30
+ "name": ("title", "text"), # text where an entry has no title (dated, list, text)
31
+ "organization": ("title",),
32
+ "university": ("title",),
33
+ "data": ("text",),
34
+ "item": ("text",),
35
+ "position": ("subtitle",),
36
+ "degree": ("subtitle",),
37
+ "honors": ("note",),
38
+ }
39
+
40
+ # Keys only v1 files use; the first one also says how to convert the file.
41
+ _V1_ONLY = {
42
+ "type", "heading", "subheading", "content", "entity", "subitems", "responsibilities",
43
+ "bibfile", "thesis_title", "show_below",
44
+ }
45
+ V1_HINT = "This looks like a v1 file; run 'cv-tools migrate' to convert it"
46
+
47
+ _TYPE_NAMES = {
48
+ "string": "a string",
49
+ "number": "a number",
50
+ "integer": "an integer",
51
+ "object": "a mapping",
52
+ "array": "a list",
53
+ "boolean": "a boolean",
54
+ "null": "null",
55
+ }
56
+
57
+
58
+ class ValidationError(CVError):
59
+ """One or more schema errors. ``errors`` holds one :class:`CVError` each."""
60
+
61
+ def __init__(self, errors: list[CVError]):
62
+ super().__init__("\n".join(str(e) for e in errors), errors[0].file)
63
+ self.errors = errors
64
+
65
+ def __str__(self) -> str:
66
+ return self.message
67
+
68
+
69
+ @lru_cache(maxsize=1)
70
+ def schema() -> dict:
71
+ text = (resources.files("cv_tools") / "schema" / "cv-v2.schema.json").read_text(encoding="utf-8")
72
+ return json.loads(text)
73
+
74
+
75
+ def format_path(path: YamlPath) -> str:
76
+ out = ""
77
+ for part in path:
78
+ out += f"[{part}]" if isinstance(part, int) else (f".{part}" if out else str(part))
79
+ return out
80
+
81
+
82
+ def suggest(word: str, choices: list[str]) -> str | None:
83
+ for alias in _ALIASES.get(word, ()):
84
+ if alias in choices:
85
+ return alias
86
+ close = difflib.get_close_matches(word, choices, n=1, cutoff=0.7)
87
+ return close[0] if close else None
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class _Problem:
92
+ path: YamlPath
93
+ message: str
94
+ at_key: bool = False # report at the mapping key rather than the value
95
+ missing: str | None = None # the key a "required" error is about
96
+ hint: str | None = None # the key an unknown key probably meant
97
+
98
+
99
+ def _problems(error: jsonschema.ValidationError) -> list[_Problem]:
100
+ path = tuple(error.absolute_path)
101
+ v, value, sch = error.validator, error.instance, error.schema
102
+ expected = sch.get("x-expected")
103
+ if v == "not" and "x-message" in sch:
104
+ return [_Problem(path, sch["x-message"])]
105
+ if v == "type" and value is None and path and isinstance(path[-1], int):
106
+ return [_Problem(path, "empty list item (null); remove the '-' line or give the item a value")]
107
+ if v == "additionalProperties":
108
+ allowed = sorted(sch.get("properties", {}))
109
+ out = []
110
+ for key in value:
111
+ if key in allowed:
112
+ continue
113
+ hint = suggest(str(key), allowed)
114
+ detail = f"; did you mean '{hint}'?" if hint else f" (expected one of: {', '.join(allowed)})"
115
+ out.append(_Problem(path + (key,), f"unknown key '{key}'{detail}", at_key=True, hint=hint))
116
+ return out
117
+ if v == "required":
118
+ missing = [k for k in error.validator_value if k not in value]
119
+ out = []
120
+ for k in missing:
121
+ message = f"missing required key '{k}'"
122
+ if k == "layout" and "type" in value:
123
+ message += f" (v1 sections had 'type'). {V1_HINT}"
124
+ out.append(_Problem(path, message, missing=k))
125
+ return out
126
+ if v == "type":
127
+ if expected is None:
128
+ types = error.validator_value if isinstance(error.validator_value, list) else [error.validator_value]
129
+ expected = " or ".join(_TYPE_NAMES.get(t, t) for t in types)
130
+ return [_Problem(path, f"expected {expected}, got {describe(value)}")]
131
+ if v == "enum":
132
+ hint = suggest(str(value), [str(c) for c in error.validator_value])
133
+ detail = f"; did you mean '{hint}'?" if hint else f" (expected one of: {', '.join(map(str, error.validator_value))})"
134
+ return [_Problem(path, f"unknown value {value!r}{detail}")]
135
+ if v == "const":
136
+ return [_Problem(path, f"expected {expected or repr(error.validator_value)}, got {value!r}")]
137
+ if v in ("minProperties", "maxProperties") and expected:
138
+ return [_Problem(path, f"expected {expected}")]
139
+ if v == "maxItems":
140
+ return [_Problem(path, f"at most {error.validator_value} items allowed, got {len(value)}")]
141
+ return [_Problem(path, error.message)]
142
+
143
+
144
+ def validate(src: Source) -> list[CVError]:
145
+ """Schema errors in a v2 document, sorted by position."""
146
+ validator = jsonschema.Draft202012Validator(schema())
147
+ problems = [p for error in validator.iter_errors(src.data) for p in _problems(error)]
148
+ # "unknown key 'titel'; did you mean 'title'?" already explains a missing title.
149
+ hinted = {(p.path[:-1], p.hint) for p in problems if p.hint}
150
+ found: dict[tuple, CVError] = {}
151
+ for p in problems:
152
+ if p.missing is not None and (p.path, p.missing) in hinted:
153
+ continue
154
+ where = format_path(p.path)
155
+ err = src.error(f"{where}: {p.message}" if where else p.message, p.path, key=p.at_key)
156
+ found.setdefault((err.line or 0, err.col or 0, err.message), err)
157
+ errors = [found[k] for k in sorted(found)]
158
+ for err in errors:
159
+ key = re.match(r"unknown key '([^']*)'", err.message.split(": ", 1)[-1])
160
+ if V1_HINT in err.message:
161
+ break
162
+ if key and key.group(1) in _V1_ONLY:
163
+ err.message += (" " if err.message.endswith("?") else ". ") + V1_HINT
164
+ break
165
+ return errors