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/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)``."""