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/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
|