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/migrate.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""v1 YAML text -> v2 YAML text, keeping comments where they have a place."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import io
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml as pyyaml
|
|
13
|
+
from ruamel.yaml import YAML
|
|
14
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
15
|
+
from ruamel.yaml.scalarstring import DoubleQuotedScalarString, SingleQuotedScalarString
|
|
16
|
+
from ruamel.yaml.error import YAMLError
|
|
17
|
+
|
|
18
|
+
from . import v1
|
|
19
|
+
from .loader import CVError, YamlPath, load_text, resolves_to_string
|
|
20
|
+
|
|
21
|
+
SCHEMA_URL = "https://cv-tools.phonethk.com/schema/v2.json"
|
|
22
|
+
HEADER = f"# yaml-language-server: $schema={SCHEMA_URL}\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Migration:
|
|
27
|
+
text: str
|
|
28
|
+
warnings: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def migrate_text(text: str, name: str = "<string>") -> Migration:
|
|
32
|
+
"""Convert v1 YAML text to v2 YAML text that renders the same PDF."""
|
|
33
|
+
src = load_text(text, name)
|
|
34
|
+
if v1.detect(src) != "v1":
|
|
35
|
+
raise CVError("the file already uses the v2 format", name)
|
|
36
|
+
conversion = v1.convert(src, clean=True)
|
|
37
|
+
doc = _commented(conversion.data)
|
|
38
|
+
_carry_comments(text, doc, conversion.origins)
|
|
39
|
+
out = _dump(doc)
|
|
40
|
+
_check(out, conversion.data, name)
|
|
41
|
+
return Migration(out, conversion.warnings)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MigrationError(CVError):
|
|
45
|
+
"""The migrated text does not load back as the converted data (a bug in cv-tools)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check(text: str, data: dict, name: str) -> None:
|
|
49
|
+
try:
|
|
50
|
+
reloaded = load_text(text, "<migrated>", yaml_version="1.2").data
|
|
51
|
+
except CVError as e:
|
|
52
|
+
problem = str(e)
|
|
53
|
+
else:
|
|
54
|
+
problem = _difference(reloaded, data, ())
|
|
55
|
+
if problem is None:
|
|
56
|
+
return
|
|
57
|
+
raise MigrationError(
|
|
58
|
+
f"internal error: the migrated file does not read back as the converted data ({problem}). "
|
|
59
|
+
"Please report this bug at https://github.com/mlsdpk/cv-tools/issues with the v1 file.",
|
|
60
|
+
name,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _difference(got: Any, want: Any, path: YamlPath) -> str | None:
|
|
65
|
+
where = "".join(f"[{p!r}]" for p in path) or "the document"
|
|
66
|
+
if isinstance(want, dict):
|
|
67
|
+
if not isinstance(got, dict) or list(got) != list(want):
|
|
68
|
+
return f"{where}: expected keys {list(want)}, got {got!r}"
|
|
69
|
+
for key in want:
|
|
70
|
+
found = _difference(got[key], want[key], path + (key,))
|
|
71
|
+
if found:
|
|
72
|
+
return found
|
|
73
|
+
return None
|
|
74
|
+
if isinstance(want, list):
|
|
75
|
+
if not isinstance(got, list) or len(got) != len(want):
|
|
76
|
+
return f"{where}: expected a list of {len(want)}, got {got!r}"
|
|
77
|
+
for i, (g, w) in enumerate(zip(got, want)):
|
|
78
|
+
found = _difference(g, w, path + (i,))
|
|
79
|
+
if found:
|
|
80
|
+
return found
|
|
81
|
+
return None
|
|
82
|
+
same = type(got) is type(want) and (got == want or (isinstance(want, float) and math.isnan(want) and math.isnan(got)))
|
|
83
|
+
return None if same else f"{where}: expected {want!r}, got {got!r}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _commented(value: Any) -> Any:
|
|
87
|
+
# Strings that YAML 1.1 reads as something else (yes, 2:1) are quoted too.
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
out = CommentedMap((k, _commented(v)) for k, v in value.items())
|
|
90
|
+
if list(value) == ["latex"]:
|
|
91
|
+
out.fa.set_flow_style()
|
|
92
|
+
if _awkward(value["latex"]):
|
|
93
|
+
out["latex"] = DoubleQuotedScalarString(value["latex"])
|
|
94
|
+
elif "\n" not in value["latex"]:
|
|
95
|
+
# ruamel writes some plain scalars in flow style that PyYAML
|
|
96
|
+
# cannot read (such as "$?"); quoted LaTeX also reads better.
|
|
97
|
+
out["latex"] = SingleQuotedScalarString(value["latex"])
|
|
98
|
+
return out
|
|
99
|
+
if isinstance(value, list):
|
|
100
|
+
return CommentedSeq(_commented(v) for v in value)
|
|
101
|
+
if isinstance(value, str) and (_awkward(value) or ("\n" not in value and not resolves_to_string(value))):
|
|
102
|
+
return DoubleQuotedScalarString(value)
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_AWKWARD = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ufeff]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _awkward(text: str) -> bool:
|
|
110
|
+
# Only a double-quoted scalar keeps these (YAML folds U+0085 and U+2028).
|
|
111
|
+
return bool(_AWKWARD.search(text))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _node(root: Any, path: YamlPath) -> Any:
|
|
115
|
+
for part in path:
|
|
116
|
+
try:
|
|
117
|
+
root = root[part]
|
|
118
|
+
except (KeyError, IndexError, TypeError):
|
|
119
|
+
return None
|
|
120
|
+
return root
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _carry_comments(text: str, doc: CommentedMap, origins: dict[YamlPath, YamlPath]) -> None:
|
|
124
|
+
try:
|
|
125
|
+
old = YAML().load(text)
|
|
126
|
+
except YAMLError:
|
|
127
|
+
return # PyYAML read it; comments are a nicety
|
|
128
|
+
if not isinstance(old, CommentedMap):
|
|
129
|
+
return
|
|
130
|
+
if old.ca.comment:
|
|
131
|
+
doc.ca.comment = copy.deepcopy(old.ca.comment)
|
|
132
|
+
for new_path, old_path in origins.items():
|
|
133
|
+
if not new_path or not old_path:
|
|
134
|
+
continue
|
|
135
|
+
new_parent, old_parent = _node(doc, new_path[:-1]), _node(old, old_path[:-1])
|
|
136
|
+
same_kind = (isinstance(new_parent, CommentedMap) and isinstance(old_parent, CommentedMap)) or (
|
|
137
|
+
isinstance(new_parent, CommentedSeq) and isinstance(old_parent, CommentedSeq)
|
|
138
|
+
)
|
|
139
|
+
if same_kind and old_path[-1] in old_parent.ca.items and new_path[-1] not in new_parent.ca.items:
|
|
140
|
+
new_parent.ca.items[new_path[-1]] = copy.deepcopy(old_parent.ca.items[old_path[-1]])
|
|
141
|
+
new_node, old_node = _node(doc, new_path), _node(old, old_path)
|
|
142
|
+
if (
|
|
143
|
+
isinstance(new_node, (CommentedMap, CommentedSeq))
|
|
144
|
+
and type(new_node) is type(old_node)
|
|
145
|
+
and old_node.ca.comment
|
|
146
|
+
and not new_node.ca.comment
|
|
147
|
+
):
|
|
148
|
+
new_node.ca.comment = copy.deepcopy(old_node.ca.comment)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Keeps ruamel.yaml from folding long scalars.
|
|
152
|
+
_UNLIMITED_WIDTH = 1 << 62
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _dump(doc: CommentedMap) -> str:
|
|
156
|
+
yaml = YAML()
|
|
157
|
+
yaml.width = _UNLIMITED_WIDTH
|
|
158
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
159
|
+
buf = io.StringIO()
|
|
160
|
+
yaml.dump(doc, buf)
|
|
161
|
+
text = buf.getvalue()
|
|
162
|
+
try:
|
|
163
|
+
root = pyyaml.compose(text, Loader=pyyaml.SafeLoader)
|
|
164
|
+
except pyyaml.YAMLError:
|
|
165
|
+
return HEADER + text # the self-check reports it
|
|
166
|
+
lines = text.splitlines(keepends=True)
|
|
167
|
+
_tidy_contact(lines, root)
|
|
168
|
+
return HEADER + _blank_lines(lines, root)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _top_level(root: Any, key: str) -> tuple[Any, Any]:
|
|
172
|
+
for key_node, value_node in getattr(root, "value", None) or []:
|
|
173
|
+
if isinstance(key_node, pyyaml.ScalarNode) and key_node.value == key:
|
|
174
|
+
return key_node, value_node
|
|
175
|
+
return None, None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _blank_lines(lines: list[str], root: Any) -> str:
|
|
179
|
+
"""A blank line before ``contact``, ``sections`` and each section, placed from the parsed nodes."""
|
|
180
|
+
starts = set()
|
|
181
|
+
for key in ("contact", "sections"):
|
|
182
|
+
key_node, _ = _top_level(root, key)
|
|
183
|
+
if key_node is not None:
|
|
184
|
+
starts.add(key_node.start_mark.line)
|
|
185
|
+
_, sections = _top_level(root, "sections")
|
|
186
|
+
if isinstance(sections, pyyaml.SequenceNode):
|
|
187
|
+
starts.update(item.start_mark.line for item in sections.value)
|
|
188
|
+
out = []
|
|
189
|
+
for i, line in enumerate(lines):
|
|
190
|
+
previous = lines[i - 1].strip() if i else ""
|
|
191
|
+
if i in starts and previous and not previous.endswith(":") and not previous.startswith("#"):
|
|
192
|
+
out.append("\n")
|
|
193
|
+
out.append(line)
|
|
194
|
+
return "".join(out)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _last_line(node: Any) -> int:
|
|
198
|
+
# A block collection's end mark is the next token, so use its last child.
|
|
199
|
+
if isinstance(node, pyyaml.ScalarNode) or node.flow_style or not node.value:
|
|
200
|
+
return node.end_mark.line
|
|
201
|
+
last = node.value[-1]
|
|
202
|
+
return _last_line(last[1] if isinstance(node, pyyaml.MappingNode) else last)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _tidy_contact(lines: list[str], root: Any) -> None:
|
|
206
|
+
"""Write a contact line as ``- - item`` instead of ruamel's ``- - item``."""
|
|
207
|
+
_, contact = _top_level(root, "contact")
|
|
208
|
+
if not isinstance(contact, pyyaml.SequenceNode):
|
|
209
|
+
return
|
|
210
|
+
for line_node in contact.value:
|
|
211
|
+
items = line_node.value if isinstance(line_node, pyyaml.SequenceNode) and not line_node.flow_style else []
|
|
212
|
+
if not items:
|
|
213
|
+
continue
|
|
214
|
+
first = items[0].start_mark.line
|
|
215
|
+
rows = [first + k for k in range(len(items))]
|
|
216
|
+
if any(item.start_mark.line != row or _last_line(item) != row for item, row in zip(items, rows)):
|
|
217
|
+
continue
|
|
218
|
+
if not lines[first].startswith(" - - ") or not all(lines[r].startswith(" - ") for r in rows[1:]):
|
|
219
|
+
continue
|
|
220
|
+
lines[first] = " - - " + lines[first][8:]
|
|
221
|
+
for r in rows[1:]:
|
|
222
|
+
lines[r] = " - " + lines[r][8:]
|
cv_tools/model.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The normalized v2 model that every renderer takes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from . import bib
|
|
10
|
+
from .loader import CVError, YamlPath
|
|
11
|
+
from .markup import Latex, Text
|
|
12
|
+
|
|
13
|
+
ICON_KINDS = ("orcid", "linkedin", "github", "twitter")
|
|
14
|
+
LINK_KINDS = ("url",) + ICON_KINDS
|
|
15
|
+
DEFAULT_SEPARATOR = " | "
|
|
16
|
+
|
|
17
|
+
# The path fields locate each value in a v2 file, for the preview; v1 input has none.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _paths() -> Any:
|
|
21
|
+
return field(default_factory=dict, compare=False, repr=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _path() -> Any:
|
|
25
|
+
return field(default=None, compare=False, repr=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ContactItem:
|
|
30
|
+
kind: str # "text" or one of LINK_KINDS
|
|
31
|
+
value: Text # a URL or ORCID iD (str or Latex) for link kinds
|
|
32
|
+
path: Optional[YamlPath] = _path() # the source of ``value``
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ContactLine:
|
|
37
|
+
items: list[ContactItem]
|
|
38
|
+
separator: Text = DEFAULT_SEPARATOR
|
|
39
|
+
path: Optional[YamlPath] = _path() # the line
|
|
40
|
+
separator_path: Optional[YamlPath] = _path() # only when the line sets ``separator``
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Entry:
|
|
45
|
+
title: Optional[Text] = None
|
|
46
|
+
date: Optional[Text] = None
|
|
47
|
+
subtitle: Optional[Text] = None
|
|
48
|
+
note: Optional[Text] = None
|
|
49
|
+
location: Optional[Text] = None
|
|
50
|
+
text: Optional[Text] = None
|
|
51
|
+
details: list[Text] = field(default_factory=list)
|
|
52
|
+
bullets: list[Text] = field(default_factory=list)
|
|
53
|
+
path: Optional[YamlPath] = _path() # the entry
|
|
54
|
+
paths: dict[str, YamlPath] = _paths()
|
|
55
|
+
|
|
56
|
+
def path_of(self, name: str, index: Optional[int] = None) -> Optional[YamlPath]:
|
|
57
|
+
"""The source of a field, or of item ``index`` of a list field."""
|
|
58
|
+
base = self.paths.get(name)
|
|
59
|
+
if base is None or index is None:
|
|
60
|
+
return base
|
|
61
|
+
return base + (index,)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Section:
|
|
66
|
+
layout: str # a v2 layout, or "newpage"
|
|
67
|
+
title: Optional[Text] = None
|
|
68
|
+
description: Optional[Text] = None
|
|
69
|
+
entries: list[Entry] = field(default_factory=list)
|
|
70
|
+
bib: Optional[Path] = None
|
|
71
|
+
highlight: list[str] = field(default_factory=list)
|
|
72
|
+
publications: list[bib.Publication] = field(default_factory=list)
|
|
73
|
+
path: Optional[YamlPath] = _path() # the section item (also for newpage)
|
|
74
|
+
paths: dict[str, YamlPath] = _paths() # "title" and "description"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Document:
|
|
79
|
+
name: Text
|
|
80
|
+
date: Text = "auto" # "auto" prints the compile date
|
|
81
|
+
contact: list[ContactLine] = field(default_factory=list)
|
|
82
|
+
sections: list[Section] = field(default_factory=list)
|
|
83
|
+
source_format: str = "v2"
|
|
84
|
+
path: Optional[Path] = None
|
|
85
|
+
warnings: list[str] = field(default_factory=list)
|
|
86
|
+
paths: dict[str, YamlPath] = _paths() # "name", and "date" when the file sets it
|
|
87
|
+
# Source.aliases and Source.anchors of a v2 file.
|
|
88
|
+
aliases: dict[YamlPath, str] = _paths()
|
|
89
|
+
anchors: dict[YamlPath, str] = _paths()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ModelError(CVError):
|
|
93
|
+
"""An error found while building the model, at a v2 data path."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, message: str, path: YamlPath):
|
|
96
|
+
super().__init__(message)
|
|
97
|
+
self.path = path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _text(value: Any) -> Optional[Text]:
|
|
101
|
+
if value is None:
|
|
102
|
+
return None
|
|
103
|
+
if isinstance(value, dict):
|
|
104
|
+
return Latex(value["latex"])
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _texts(values: Optional[list]) -> list[Text]:
|
|
109
|
+
return [_text(v) for v in values or []]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_ENTRY_FIELDS = ("title", "date", "subtitle", "note", "location", "text", "details", "bullets")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _entry(data: Any, at: Optional[YamlPath] = None) -> Entry:
|
|
116
|
+
# A plain text value is shorthand for {text: ...}.
|
|
117
|
+
if not isinstance(data, dict) or "latex" in data:
|
|
118
|
+
return Entry(text=_text(data), path=at, paths={} if at is None else {"text": at})
|
|
119
|
+
return Entry(
|
|
120
|
+
title=_text(data.get("title")),
|
|
121
|
+
date=_text(data.get("date")),
|
|
122
|
+
subtitle=_text(data.get("subtitle")),
|
|
123
|
+
note=_text(data.get("note")),
|
|
124
|
+
location=_text(data.get("location")),
|
|
125
|
+
text=_text(data.get("text")),
|
|
126
|
+
details=_texts(data.get("details")),
|
|
127
|
+
bullets=_texts(data.get("bullets")),
|
|
128
|
+
path=at,
|
|
129
|
+
paths={} if at is None else {k: at + (k,) for k in _ENTRY_FIELDS if k in data},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _contact_line(data: Any, at: Optional[YamlPath] = None) -> ContactLine:
|
|
134
|
+
items_at: Optional[YamlPath] = at
|
|
135
|
+
separator_at: Optional[YamlPath] = None
|
|
136
|
+
if isinstance(data, dict):
|
|
137
|
+
items, separator = data["items"], _text(data.get("separator", DEFAULT_SEPARATOR))
|
|
138
|
+
if at is not None:
|
|
139
|
+
items_at = at + ("items",)
|
|
140
|
+
separator_at = at + ("separator",) if "separator" in data else None
|
|
141
|
+
else:
|
|
142
|
+
items, separator = data, DEFAULT_SEPARATOR
|
|
143
|
+
out = []
|
|
144
|
+
for j, item in enumerate(items):
|
|
145
|
+
item_at = None if items_at is None else items_at + (j,)
|
|
146
|
+
if isinstance(item, dict) and "latex" not in item:
|
|
147
|
+
(kind, value), = item.items()
|
|
148
|
+
out.append(ContactItem(kind, _text(value), None if item_at is None else item_at + (kind,)))
|
|
149
|
+
else:
|
|
150
|
+
out.append(ContactItem("text", _text(item), item_at))
|
|
151
|
+
return ContactLine(out, separator, at, separator_at)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def from_data(data: dict, base_dir: Path, source_format: str = "v2", path: Optional[Path] = None) -> Document:
|
|
155
|
+
"""Build the model from schema-valid v2 data and load the .bib files."""
|
|
156
|
+
def at(*parts) -> Optional[YamlPath]:
|
|
157
|
+
return tuple(parts) if source_format == "v2" else None
|
|
158
|
+
|
|
159
|
+
name = _text(data["name"])
|
|
160
|
+
default_highlight = [name.latex if isinstance(name, Latex) else str(name)]
|
|
161
|
+
doc = Document(
|
|
162
|
+
name=name,
|
|
163
|
+
date=_text(data.get("date", "auto")),
|
|
164
|
+
contact=[_contact_line(line, at("contact", j)) for j, line in enumerate(data.get("contact", []))],
|
|
165
|
+
source_format=source_format,
|
|
166
|
+
path=path,
|
|
167
|
+
)
|
|
168
|
+
if source_format == "v2":
|
|
169
|
+
doc.paths = {k: (k,) for k in ("name", "date") if k in data}
|
|
170
|
+
for i, section in enumerate(data.get("sections", [])):
|
|
171
|
+
if section == "newpage":
|
|
172
|
+
doc.sections.append(Section("newpage", path=at("sections", i)))
|
|
173
|
+
continue
|
|
174
|
+
s = Section(
|
|
175
|
+
layout=section["layout"],
|
|
176
|
+
title=_text(section["title"]),
|
|
177
|
+
description=_text(section.get("description")),
|
|
178
|
+
entries=[_entry(e, at("sections", i, "entries", j)) for j, e in enumerate(section.get("entries", []))],
|
|
179
|
+
path=at("sections", i),
|
|
180
|
+
)
|
|
181
|
+
if source_format == "v2":
|
|
182
|
+
s.paths = {k: ("sections", i, k) for k in ("title", "description") if k in section}
|
|
183
|
+
if s.layout == "publications":
|
|
184
|
+
s.bib = (base_dir / section["bib"]).resolve()
|
|
185
|
+
s.highlight = list(section.get("highlight", default_highlight))
|
|
186
|
+
try:
|
|
187
|
+
s.publications, warnings = bib.load(s.bib, s.highlight)
|
|
188
|
+
except CVError as e:
|
|
189
|
+
raise ModelError(e.message, ("sections", i, "bib")) from None
|
|
190
|
+
doc.warnings.extend(warnings)
|
|
191
|
+
doc.sections.append(s)
|
|
192
|
+
return doc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Renderers for the normalized v2 model. Each backend exposes ``render(doc)``."""
|