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/loader.py ADDED
@@ -0,0 +1,424 @@
1
+ """YAML loading with duplicate-key rejection and source positions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Optional, Union
10
+
11
+ import yaml
12
+ from yaml.events import AliasEvent
13
+ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
14
+
15
+ YamlPath = tuple[Union[str, int], ...]
16
+
17
+ _TIMESTAMP = "tag:yaml.org,2002:timestamp"
18
+ _MERGE = "tag:yaml.org,2002:merge"
19
+ _MERGE_KEY = object()
20
+
21
+ # Stops alias bombs ("billion laughs"); far more than any CV repeats.
22
+ MAX_ALIAS_NODES = 10_000
23
+
24
+ # Deeper YAML would exhaust Python's recursion limit while it is read.
25
+ MAX_DEPTH = 100
26
+
27
+ # sys.get_int_max_str_digits: longer integers load as the string written.
28
+ _MAX_INT_DIGITS = 4300
29
+ _INT_LIMIT = 10**_MAX_INT_DIGITS
30
+
31
+
32
+ class CVError(Exception):
33
+ """An error in a CV file, printed as ``file:line:col: message``."""
34
+
35
+ def __init__(self, message: str, file: str | None = None, line: int | None = None, col: int | None = None):
36
+ super().__init__(message)
37
+ self.message = message
38
+ self.file = file
39
+ self.line = line
40
+ self.col = col
41
+
42
+ def __str__(self) -> str:
43
+ return f"{self.location}: {self.message}" if self.location else self.message
44
+
45
+ @property
46
+ def location(self) -> str:
47
+ return ":".join(str(p) for p in (self.file, self.line, self.col) if p is not None)
48
+
49
+ def format(self, severity: str = "error") -> str:
50
+ return f"{self.location}: {severity}: {self.message}" if self.location else f"{severity}: {self.message}"
51
+
52
+
53
+ @dataclass
54
+ class Source:
55
+ """A parsed YAML document and the position of every node in it."""
56
+
57
+ data: Any
58
+ name: str
59
+ text: str
60
+ base_dir: Path
61
+ positions: dict[YamlPath, tuple[int, int]] = field(default_factory=dict)
62
+ key_positions: dict[YamlPath, tuple[int, int]] = field(default_factory=dict)
63
+ yaml_version: str = "1.1" # "1.2" for a v2 file
64
+ # Paths reached through an alias or merge key, and paths inside a used
65
+ # anchor, each with its anchor name (web-spec section 4.3).
66
+ aliases: dict[YamlPath, str] = field(default_factory=dict)
67
+ anchors: dict[YamlPath, str] = field(default_factory=dict)
68
+
69
+ def position(self, path: YamlPath, key: bool = False) -> tuple[int, int] | None:
70
+ """1-based (line, column) of ``path`` or its closest ancestor; ``key`` prefers the key's position."""
71
+ path = tuple(path)
72
+ if key and path in self.key_positions:
73
+ return self.key_positions[path]
74
+ while path not in self.positions and path:
75
+ path = path[:-1]
76
+ return self.positions.get(path)
77
+
78
+ def error(self, message: str, path: YamlPath = (), key: bool = False) -> CVError:
79
+ pos = self.position(path, key)
80
+ return CVError(message, self.name, *(pos or (None, None)))
81
+
82
+
83
+ class _Loader(yaml.SafeLoader):
84
+ """YAML 1.1 as v1 read it, but with duplicate keys rejected and timestamps kept as strings."""
85
+
86
+ yaml_implicit_resolvers = {
87
+ first: [r for r in resolvers if r[0] != _TIMESTAMP]
88
+ for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
89
+ }
90
+
91
+ def __init__(self, stream):
92
+ super().__init__(stream)
93
+ self.anchor_of: dict[int, str] = {}
94
+ self.aliased: set[int] = set()
95
+ # (id(parent), index or id(key node)) -> (anchor, mark)
96
+ self.alias_slots: dict[tuple[int, Any], tuple[str, Any]] = {}
97
+ # (id(mapping), index of the pair) -> (anchor, mark), for aliases used as keys
98
+ self.alias_keys: dict[tuple[int, int], tuple[str, Any]] = {}
99
+ self.depth = 0
100
+
101
+ def compose_node(self, parent, index):
102
+ event = self.peek_event()
103
+ self.depth += 1
104
+ if self.depth > MAX_DEPTH:
105
+ raise yaml.composer.ComposerError(
106
+ None, None, f"the YAML is nested more than {MAX_DEPTH} levels deep", event.start_mark)
107
+ try:
108
+ node = super().compose_node(parent, index)
109
+ finally:
110
+ self.depth -= 1
111
+ if isinstance(event, AliasEvent):
112
+ self.aliased.add(id(node))
113
+ if parent is not None and index is not None:
114
+ slot = id(index) if isinstance(index, Node) else index
115
+ self.alias_slots[(id(parent), slot)] = (event.anchor, event.start_mark)
116
+ elif isinstance(parent, MappingNode):
117
+ self.alias_keys[(id(parent), len(parent.value))] = (event.anchor, event.start_mark)
118
+ elif event.anchor is not None:
119
+ self.anchor_of[id(node)] = event.anchor
120
+ return node
121
+
122
+ def construct_yaml_int(self, node):
123
+ value = self.construct_scalar(node)
124
+ try:
125
+ number = super().construct_yaml_int(node)
126
+ except ValueError:
127
+ return value
128
+ return value if abs(number) >= _INT_LIMIT else number
129
+
130
+ def construct_yaml_float(self, node):
131
+ return _float_or_text(self.construct_scalar(node), super().construct_yaml_float(node))
132
+
133
+ def construct_mapping(self, node, deep=False):
134
+ if isinstance(node, MappingNode):
135
+ seen: dict[Any, Any] = {}
136
+ for key_node, _ in node.value:
137
+ merge = key_node.tag == _MERGE
138
+ key = _MERGE_KEY if merge else self.construct_object(key_node, deep=True)
139
+ try:
140
+ first = seen.get(key)
141
+ except TypeError:
142
+ continue # unhashable; the base class reports it
143
+ if first is not None:
144
+ raise yaml.constructor.ConstructorError(
145
+ None,
146
+ None,
147
+ f"duplicate key '{'<<' if merge else key}' (first defined on line {first.line + 1})",
148
+ key_node.start_mark,
149
+ )
150
+ seen[key] = key_node.start_mark
151
+ return super().construct_mapping(node, deep)
152
+
153
+
154
+ _Loader.add_constructor("tag:yaml.org,2002:int", _Loader.construct_yaml_int)
155
+ _Loader.add_constructor("tag:yaml.org,2002:float", _Loader.construct_yaml_float)
156
+
157
+
158
+ def _float_or_text(text: str, number: float) -> Union[float, str]:
159
+ # Out of range: keep the text written, as for huge integers.
160
+ if math.isinf(number) and "inf" not in text.lower():
161
+ return text
162
+ if number == 0 and re.search("[1-9]", text.lower().partition("e")[0]):
163
+ return text
164
+ return number
165
+
166
+
167
+ # YAML 1.2 core schema, as JSON Schema and JavaScript YAML libraries read it.
168
+ _CORE_RESOLVERS = [
169
+ ("tag:yaml.org,2002:bool", r"^(?:true|True|TRUE|false|False|FALSE)$", "tTfF"),
170
+ ("tag:yaml.org,2002:int", r"^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$", "-+0123456789"),
171
+ (
172
+ "tag:yaml.org,2002:float",
173
+ r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?"
174
+ r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$",
175
+ "-+.0123456789",
176
+ ),
177
+ ("tag:yaml.org,2002:null", r"^(?:~|null|Null|NULL|)$", ["~", "n", "N", ""]),
178
+ ("tag:yaml.org,2002:merge", r"^(?:<<)$", "<"),
179
+ ]
180
+
181
+
182
+ class _CoreLoader(_Loader):
183
+ """YAML 1.2 core schema scalars, for v2 files (``yes`` and ``10:30`` stay strings)."""
184
+
185
+ yaml_implicit_resolvers: dict = {}
186
+
187
+
188
+ for _tag, _pattern, _first in _CORE_RESOLVERS:
189
+ _CoreLoader.add_implicit_resolver(_tag, re.compile(_pattern), list(_first))
190
+
191
+
192
+ def _core_int(loader: _CoreLoader, node: ScalarNode) -> Union[int, str]:
193
+ value = loader.construct_scalar(node)
194
+ if value.startswith("0o"):
195
+ number = int(value[2:], 8)
196
+ elif value.startswith("0x"):
197
+ number = int(value[2:], 16)
198
+ else:
199
+ digits = value.lstrip("+-").lstrip("0")
200
+ if len(digits) > _MAX_INT_DIGITS:
201
+ return value
202
+ number = int((value[0] if value[0] in "+-" else "") + (digits or "0"), 10)
203
+ return value if abs(number) >= _INT_LIMIT else number
204
+
205
+
206
+ def _core_float(loader: _CoreLoader, node: ScalarNode) -> Union[float, str]:
207
+ text = loader.construct_scalar(node)
208
+ value = text.lower()
209
+ sign = -1.0 if value.startswith("-") else 1.0
210
+ if value.lstrip("+-") == ".inf":
211
+ return sign * float("inf")
212
+ if value == ".nan":
213
+ return float("nan")
214
+ return _float_or_text(text, float(value))
215
+
216
+
217
+ _CoreLoader.add_constructor("tag:yaml.org,2002:int", _core_int)
218
+ _CoreLoader.add_constructor("tag:yaml.org,2002:float", _core_float)
219
+
220
+ _LOADERS = {"1.1": _Loader, "1.2": _CoreLoader}
221
+
222
+
223
+ def describe(value: Any) -> str:
224
+ """The type of a YAML value as error messages name it."""
225
+ if value is None:
226
+ return "null"
227
+ if isinstance(value, bool):
228
+ return "a boolean"
229
+ if isinstance(value, (int, float)):
230
+ return "a number"
231
+ if isinstance(value, str):
232
+ return "a string"
233
+ if isinstance(value, list):
234
+ return "a list"
235
+ if isinstance(value, dict):
236
+ return "a mapping"
237
+ return type(value).__name__
238
+
239
+
240
+ def _mark(m) -> tuple[int, int]:
241
+ return (m.line + 1, m.column + 1)
242
+
243
+
244
+ class _Walk:
245
+ """Records positions, aliases and anchors, and enforces the alias budget."""
246
+
247
+ def __init__(self, loader: _Loader, src: Source):
248
+ self.loader = loader
249
+ self.src = src
250
+ self.stack: set[int] = set()
251
+ self.expanded = 0
252
+ self.anchor_around: dict[int, str] = {}
253
+
254
+ def alias(self, parent: Node, index: Any) -> Optional[tuple[str, Any]]:
255
+ slot = id(index) if isinstance(index, Node) else index
256
+ return self.loader.alias_slots.get((id(parent), slot))
257
+
258
+ def members(self, node: MappingNode, outer: Optional[tuple[str, Any]]) -> list[tuple]:
259
+ """``(owner, key node, value node, via, outermost via)`` per key, merges resolved as PyYAML does."""
260
+ out: list[tuple] = []
261
+ seen: set[Any] = set()
262
+
263
+ def add(mapping: MappingNode, via: Optional[tuple[str, Any]], top: Optional[tuple[str, Any]],
264
+ depth: int) -> None:
265
+ if depth:
266
+ if id(mapping) in self.stack or depth > 64:
267
+ raise self._error("a merge key refers to a mapping that contains it", via, mapping)
268
+ # Each merged mapping counts, or a merge bomb would walk forever.
269
+ self.count(outer or top, mapping)
270
+ merges = []
271
+ for i, (key_node, value_node) in enumerate(mapping.value):
272
+ if key_node.tag == _MERGE:
273
+ merges.append((key_node, value_node))
274
+ continue
275
+ if not isinstance(key_node, ScalarNode):
276
+ continue
277
+ key = self.loader.construct_object(key_node)
278
+ if key not in seen:
279
+ seen.add(key)
280
+ # A value under an alias key has no path that setIn can reach.
281
+ key_alias = self.loader.alias_keys.get((id(mapping), i))
282
+ out.append((mapping, key_node, value_node, via or key_alias, top or key_alias))
283
+ for key_node, value_node in merges:
284
+ alias = self.alias(mapping, key_node)
285
+ if isinstance(value_node, MappingNode):
286
+ sources = [(value_node, alias)]
287
+ elif isinstance(value_node, SequenceNode):
288
+ sources = [(m, self.alias(value_node, i)) for i, m in enumerate(value_node.value)]
289
+ else:
290
+ continue # not a mapping; the constructor reports it
291
+ for source, source_alias in sources:
292
+ if not isinstance(source, MappingNode):
293
+ continue
294
+ name = source_alias or (self.loader.anchor_of.get(id(source), "<<"), source.start_mark)
295
+ add(source, name, top or name, depth + 1)
296
+
297
+ add(node, None, None, 0)
298
+ return out
299
+
300
+ def count(self, outer: Optional[tuple[str, Any]], node: Node) -> None:
301
+ self.expanded += 1
302
+ if self.expanded > MAX_ALIAS_NODES:
303
+ raise self._error(
304
+ f"aliases expand to more than {MAX_ALIAS_NODES} nodes; "
305
+ "write the repeated values out or use fewer aliases", outer, node)
306
+
307
+ def _error(self, message: str, via: Optional[tuple[str, Any]], node: Node) -> CVError:
308
+ mark = via[1] if via else node.start_mark
309
+ return CVError(message, self.src.name, *_mark(mark))
310
+
311
+ def record(self, node: Node, path: YamlPath, via: Optional[str], anchor: Optional[str],
312
+ alias: Optional[tuple[str, Any]] = None, outer: Optional[tuple[str, Any]] = None) -> None:
313
+ """Record ``node`` and everything below it. ``via`` is the last alias on
314
+ the path, ``outer`` the first one (where a budget error points)."""
315
+ src = self.src
316
+ src.positions.setdefault(path, _mark(node.start_mark))
317
+ if alias is not None:
318
+ if id(node) in self.stack:
319
+ raise self._error(f"alias *{alias[0]} refers to a node that contains it", alias, node)
320
+ via, outer = alias[0], outer or alias
321
+ if via is not None:
322
+ self.count(outer, node)
323
+ # The anchor around the original, so it matches that place's data-anchor.
324
+ src.aliases[path] = self.anchor_around.get(id(node), via)
325
+ else:
326
+ if id(node) in self.loader.aliased and id(node) in self.loader.anchor_of:
327
+ anchor = self.loader.anchor_of[id(node)]
328
+ if anchor is not None:
329
+ src.anchors[path] = anchor
330
+ self.anchor_around.setdefault(id(node), anchor)
331
+ if not isinstance(node, (MappingNode, SequenceNode)):
332
+ return
333
+ self.stack.add(id(node))
334
+ if isinstance(node, MappingNode):
335
+ for owner, key_node, value_node, merged, top in self.members(node, outer):
336
+ child = path + (self.loader.construct_object(key_node),)
337
+ src.key_positions.setdefault(child, _mark(key_node.start_mark))
338
+ child_via, child_outer = (merged[0], outer or top) if merged else (via, outer)
339
+ self.record(value_node, child, child_via, anchor, self.alias(owner, key_node), child_outer)
340
+ else:
341
+ for i, item in enumerate(node.value):
342
+ self.record(item, path + (i,), via, anchor, self.alias(node, i), outer)
343
+ self.stack.discard(id(node))
344
+
345
+
346
+ def _record(loader: _Loader, node: Node, src: Source) -> None:
347
+ _Walk(loader, src).record(node, (), None, None)
348
+
349
+
350
+ def load_text(text: str, name: str = "<string>", base_dir: Path | None = None, yaml_version: str = "auto") -> Source:
351
+ """Parse YAML text, raising :class:`CVError` with a position on any error.
352
+
353
+ ``"auto"`` reads YAML 1.2 when the file has a ``version`` other than 1, else YAML 1.1.
354
+ """
355
+ if yaml_version != "auto":
356
+ return _parse(text, name, base_dir, _LOADERS[yaml_version])
357
+ try:
358
+ src = _parse(text, name, base_dir, _CoreLoader)
359
+ except CVError as e:
360
+ try:
361
+ src = _parse(text, name, base_dir, _Loader)
362
+ except CVError:
363
+ raise e from None
364
+ if isinstance(src.data, dict) and "version" in src.data:
365
+ raise e from None
366
+ return src
367
+ if isinstance(src.data, dict) and "version" in src.data and not _is_v1(src.data["version"]):
368
+ return src
369
+ return _parse(text, name, base_dir, _Loader)
370
+
371
+
372
+ def _is_v1(version: Any) -> bool:
373
+ return not isinstance(version, bool) and isinstance(version, (int, float)) and version == 1
374
+
375
+
376
+ def _parse(text: str, name: str, base_dir: Path | None, loader_class: type) -> Source:
377
+ src = Source(None, name, text, base_dir or Path.cwd())
378
+ src.yaml_version = "1.2" if loader_class is _CoreLoader else "1.1"
379
+ loader = None
380
+ try:
381
+ loader = loader_class(text)
382
+ node = loader.get_single_node()
383
+ if node is not None:
384
+ _record(loader, node, src)
385
+ src.data = loader.construct_document(node)
386
+ except yaml.reader.ReaderError as e:
387
+ raise CVError(f"character U+{e.character:04X} is not allowed in YAML", name,
388
+ *_text_position(text, e.position)) from None
389
+ except yaml.MarkedYAMLError as e:
390
+ mark = e.problem_mark or e.context_mark
391
+ problem = e.problem or e.context or "invalid YAML"
392
+ raise CVError(problem, name, *(_mark(mark) if mark else (None, None))) from None
393
+ except yaml.YAMLError as e:
394
+ raise CVError(f"invalid YAML: {e}", name) from None
395
+ finally:
396
+ if loader is not None:
397
+ loader.dispose()
398
+ return src
399
+
400
+
401
+ def _text_position(text: str, index: int) -> tuple[int, int]:
402
+ before = re.sub("\r\n", "\n", text[:index])
403
+ lines = re.split("[\n\r\x85\u2028\u2029]", before)
404
+ return len(lines), len(lines[-1]) + 1
405
+
406
+
407
+ def resolves_to_string(value: str) -> bool:
408
+ """True if ``value`` as a plain scalar is a string in YAML 1.1 and 1.2."""
409
+ for resolver in (yaml.SafeLoader, _CoreLoader):
410
+ tag = resolver.resolve(resolver, ScalarNode, value, (True, False)) # type: ignore[arg-type]
411
+ if tag != "tag:yaml.org,2002:str":
412
+ return False
413
+ return True
414
+
415
+
416
+ def load_file(path: str | Path) -> Source:
417
+ path = Path(path)
418
+ try:
419
+ text = path.read_text(encoding="utf-8")
420
+ except OSError as e:
421
+ raise CVError(f"cannot read file: {e.strerror}", str(path)) from None
422
+ except UnicodeDecodeError:
423
+ raise CVError("file is not valid UTF-8", str(path)) from None
424
+ return load_text(text, str(path), path.resolve().parent)