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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """cv-tools: generate a LaTeX CV from a YAML file."""
2
+
3
+ __version__ = "2.0.0"
4
+
5
+ from .api import build_pdf, load, migrate_text, render_html, render_tex # noqa: E402
6
+
7
+ __all__ = ["__version__", "build_pdf", "load", "migrate_text", "render_html", "render_tex"]
cv_tools/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
cv_tools/api.py ADDED
@@ -0,0 +1,123 @@
1
+ """The public API: load a CV, then render it to LaTeX, HTML or PDF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import sys
7
+ import warnings
8
+ from pathlib import Path
9
+ from typing import Optional, Union
10
+
11
+ from . import engine as _engine
12
+ from . import charset, model, v1
13
+ from .loader import CVError, Source, YamlPath, load_file, load_text
14
+ from .migrate import migrate_text as _migrate
15
+ from .model import Document
16
+ from .render import html, latex
17
+ from .render.html import HtmlResult
18
+ from .render.latex import RenderResult
19
+ from .validate import ValidationError, validate
20
+
21
+ __all__ = [
22
+ "CVError", "Document", "HtmlResult", "RenderResult", "ValidationError",
23
+ "build_pdf", "load", "load_with_v1_warnings", "loads", "migrate_text", "render_html", "render_tex",
24
+ "split_path",
25
+ ]
26
+
27
+
28
+ def load(path: Union[str, Path]) -> Document:
29
+ """Load a v1 or v2 CV file into the v2 model (a v1 file also warns on stderr)."""
30
+ return _document(load_file(path))[0]
31
+
32
+
33
+ def load_with_v1_warnings(path: Union[str, Path]) -> tuple[Document, list[str]]:
34
+ """Like :func:`load`, and also return the v1 conversion warnings on their own."""
35
+ return _document(load_file(path))
36
+
37
+
38
+ def loads(text: str, name: str = "<string>", base_dir: Optional[Path] = None) -> Document:
39
+ """Like :func:`load`, for YAML text. Relative .bib paths resolve against ``base_dir``."""
40
+ return _document(load_text(text, name, base_dir))[0]
41
+
42
+
43
+ def _document(src: Source) -> tuple[Document, list[str]]:
44
+ path = Path(src.name) if src.name != "<string>" else None
45
+ if v1.detect(src) == "v2":
46
+ errors = validate(src)
47
+ if errors:
48
+ raise ValidationError(errors)
49
+ doc = _checked(src, src.data, "v2", path, lambda p: p)
50
+ doc.aliases, doc.anchors = src.aliases, src.anchors
51
+ return doc, []
52
+
53
+ message = v1.DEPRECATION.format(file=src.name)
54
+ print(message, file=sys.stderr)
55
+ warnings.warn(message, DeprecationWarning, stacklevel=3)
56
+ conversion = v1.convert(src)
57
+ doc = _checked(src, conversion.data, "v1", path, lambda p: _v1_path(conversion.origins, p))
58
+ doc.warnings[:0] = conversion.warnings
59
+ return doc, list(conversion.warnings)
60
+
61
+
62
+ def _checked(src: Source, data: dict, fmt: str, path: Optional[Path], source_path) -> Document:
63
+ # pdflatex must be able to print every character of the text and .bib fields.
64
+ errors = charset.check_data(src, data, source_path, show_path=fmt == "v2")
65
+ if errors:
66
+ raise ValidationError(errors)
67
+ doc = _from_data(src, data, fmt, path, source_path)
68
+ errors = [e for s in doc.sections if s.bib is not None for e in charset.check_publications(s.bib, s.publications)]
69
+ if errors:
70
+ raise ValidationError(errors)
71
+ return doc
72
+
73
+
74
+ def _v1_path(origins: dict[YamlPath, YamlPath], path: YamlPath) -> YamlPath:
75
+ while path and path not in origins:
76
+ path = path[:-1]
77
+ return origins.get(path, ())
78
+
79
+
80
+ def _from_data(src: Source, data: dict, fmt: str, path: Optional[Path], source_path) -> Document:
81
+ try:
82
+ return model.from_data(data, src.base_dir, fmt, path)
83
+ except model.ModelError as e:
84
+ raise src.error(e.message, source_path(e.path)) from None
85
+
86
+
87
+ def render_tex(doc: Document) -> RenderResult:
88
+ """Render the LaTeX source and the support files it needs."""
89
+ return latex.render(doc)
90
+
91
+
92
+ def render_html(doc: Document, today: Optional[datetime.date] = None) -> HtmlResult:
93
+ """Render the HTML live preview. ``today`` is the date printed for ``date: auto``."""
94
+ return html.render(doc, today=today)
95
+
96
+
97
+ def split_path(path: str) -> tuple[Union[str, int], ...]:
98
+ """A ``data-path`` value as a YAML path."""
99
+ return html.split_path(path)
100
+
101
+
102
+ def build_pdf(
103
+ doc: Document,
104
+ out: Union[str, Path, None] = None,
105
+ engine: str = "auto",
106
+ source_date_epoch: Optional[int] = None,
107
+ ) -> bytes:
108
+ """Compile the CV to PDF with ``engine`` (auto, pdflatex or docker), write ``out`` if given, and return the bytes."""
109
+ workdir = Path(out).resolve().parent if out is not None else Path.cwd()
110
+ pdf = _engine.compile_pdf(render_tex(doc), engine=engine, source_date_epoch=source_date_epoch, workdir=workdir)
111
+ if out is not None:
112
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
113
+ Path(out).write_bytes(pdf)
114
+ return pdf
115
+
116
+
117
+ def migrate_text(v1_text: str, name: str = "<string>") -> str:
118
+ """Convert v1 YAML text to v2 YAML text. Warnings go to ``warnings.warn``."""
119
+ migration = _migrate(v1_text, name)
120
+ for warning in migration.warnings:
121
+ warnings.warn(warning, UserWarning, stacklevel=2)
122
+ return migration.text
123
+
cv_tools/bib.py ADDED
@@ -0,0 +1,122 @@
1
+ """BibTeX loading, author names and highlight matching. Fields are LaTeX, printed as written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import re
7
+ import unicodedata
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import latexcodec # noqa: F401 (registers the "ulatex" codec; a pybtex dependency)
12
+
13
+ from pybtex.database import Person, parse_file
14
+ from pybtex.exceptions import PybtexError
15
+
16
+ from .loader import CVError
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Author:
21
+ name: str # LaTeX, as written in the .bib file
22
+ highlighted: bool
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Publication:
27
+ """One .bib entry. Text fields are LaTeX from the .bib file; None when missing."""
28
+
29
+ key: str
30
+ authors: tuple[Author, ...]
31
+ title: str | None
32
+ venue: str | None
33
+ year: str | None
34
+ url: str | None # a plain URL, BibTeX escapes resolved
35
+ venue_field: str | None = None # "journal" or "booktitle", where the venue came from
36
+
37
+
38
+ def format_name(person: Person) -> str:
39
+ """The full name with its von and Jr. parts, which cv-tools 1.x dropped."""
40
+ parts = (
41
+ person.first_names + person.middle_names + person.prelast_names + person.last_names + person.lineage_names
42
+ )
43
+ return " ".join(" ".join(parts).split())
44
+
45
+
46
+ def bib_url(url: str) -> str:
47
+ """A BibTeX ``url`` field as a plain URL, escapes and braces resolved."""
48
+ url = re.sub(r"\\([%#_&$~])", r"\1", url)
49
+ return re.sub(r"(?<!\\)[{}]", "", url).strip()
50
+
51
+
52
+ def _decode(name: str) -> str:
53
+ """LaTeX accents to Unicode, so ``Jos{\\'e}`` and ``José`` compare equal."""
54
+ try:
55
+ name = codecs.decode(name, "ulatex")
56
+ except (ValueError, LookupError): # pragma: no cover - latexcodec passes unknown input through
57
+ pass
58
+ return unicodedata.normalize("NFC", name)
59
+
60
+
61
+ def _normalize(name: str) -> str:
62
+ name = _decode(name).replace("{", "").replace("}", "")
63
+ return " ".join(name.split()).casefold()
64
+
65
+
66
+ def _join(*parts: str) -> str:
67
+ return " ".join(p for p in parts if p)
68
+
69
+
70
+ def _name_forms(person: Person) -> set[str]:
71
+ first = " ".join(person.first_names + person.middle_names)
72
+ last = _join(*person.prelast_names, *person.last_names)
73
+ jr = " ".join(person.lineage_names)
74
+ forms = {format_name(person), _join(first, last) + (f", {jr}" if jr else "")}
75
+ if first:
76
+ forms.add(f"{last}, {jr}, {first}" if jr else f"{last}, {first}")
77
+ if jr:
78
+ forms.add(f"{last} {jr}, {first}")
79
+ return {_normalize(f) for f in forms}
80
+
81
+
82
+ def matches(person: Person, names: list[str]) -> bool:
83
+ """True if any of ``names`` is this author's full name, ignoring case, braces, spacing and accent spelling."""
84
+ wanted = {_normalize(n) for n in names} - {""}
85
+ return bool(wanted & _name_forms(person))
86
+
87
+
88
+ def _field(fields, name: str) -> str | None:
89
+ value = fields.get(name)
90
+ return value if value is not None and value.strip() else None
91
+
92
+
93
+ def load(path: Path, highlight: list[str]) -> tuple[list[Publication], list[str]]:
94
+ """Entries of a .bib file in file order, and warnings about missing fields."""
95
+ if not Path(path).is_file():
96
+ raise CVError(f"BibTeX file not found: {path}")
97
+ try:
98
+ data = parse_file(str(path), bib_format="bibtex")
99
+ except (PybtexError, UnicodeDecodeError) as e:
100
+ raise CVError(f"cannot read BibTeX file {path}: {e}") from None
101
+
102
+ publications, warnings = [], []
103
+ for key, entry in data.entries.items():
104
+ persons = entry.persons.get("author", [])
105
+ authors = tuple(Author(format_name(p), matches(p, highlight)) for p in persons)
106
+ fields = entry.fields
107
+ title, year = _field(fields, "title"), _field(fields, "year")
108
+ missing = [f for f, present in (("author", authors), ("title", title), ("year", year)) if not present]
109
+ for name in missing:
110
+ warnings.append(f"{path}: warning: entry '{key}' has no {name}; it is left out of the citation")
111
+ publications.append(
112
+ Publication(
113
+ key=key,
114
+ authors=authors,
115
+ title=title,
116
+ venue=_field(fields, "journal") or _field(fields, "booktitle"),
117
+ venue_field="journal" if _field(fields, "journal") else "booktitle" if _field(fields, "booktitle") else None,
118
+ year=year,
119
+ url=bib_url(fields["url"]) if _field(fields, "url") else None,
120
+ )
121
+ )
122
+ return publications, warnings
cv_tools/charset.py ADDED
@@ -0,0 +1,142 @@
1
+ """Checks that pdflatex can print every character (``templates/latex/unicode.txt``) before it runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ from functools import lru_cache
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Iterator, Optional
11
+
12
+ from .loader import CVError, Source, YamlPath
13
+ from .markup import Latex, to_latex
14
+
15
+ # Keys whose values are never printed as text.
16
+ _NOT_TEXT = frozenset({"version", "layout", "bib", "highlight", "url", "orcid", "linkedin", "github", "twitter"})
17
+
18
+ # hyperref reads URL arguments with other catcodes, so any character works there.
19
+ _URL_COMMAND = re.compile(r"\\(?:href|url)\s*\{")
20
+
21
+ _YAML_LINE_BREAK = re.compile("\r\n|[\n\r\x85\u2028\u2029]")
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def supported() -> frozenset[int]:
26
+ """Code points above ASCII that print in every font of the class."""
27
+ text = (resources.files("cv_tools") / "templates" / "latex" / "unicode.txt").read_text(encoding="utf-8")
28
+ return frozenset(int(line.split()[0], 16) for line in text.splitlines() if line and not line.startswith("#"))
29
+
30
+
31
+ def prints(c: str) -> bool:
32
+ return ord(c) < 0x80 or ord(c) in supported()
33
+
34
+
35
+ def _without_urls(latex: str) -> str:
36
+ out, i = [], 0
37
+ for match in _URL_COMMAND.finditer(latex):
38
+ if match.start() < i:
39
+ continue
40
+ depth, j = 1, match.end()
41
+ while j < len(latex) and depth:
42
+ if latex[j] == "\\":
43
+ j += 2
44
+ continue
45
+ depth += {"{": 1, "}": -1}.get(latex[j], 0)
46
+ j += 1
47
+ out.append(latex[i : match.end()])
48
+ i = j - 1 if depth == 0 else len(latex)
49
+ out.append(latex[i:])
50
+ return "".join(out)
51
+
52
+
53
+ def problems(latex: str, uppercased: bool = False) -> list[tuple[str, Optional[str]]]:
54
+ """``(character, upper-case form)`` for each character of ``latex`` that pdflatex cannot print."""
55
+ # The upper-case form is set when only it fails (titles are upper-cased).
56
+ found: dict[str, Optional[str]] = {}
57
+ for c in _without_urls(latex):
58
+ if c in found:
59
+ continue
60
+ if not prints(c):
61
+ found[c] = None
62
+ elif uppercased and not all(prints(u) for u in c.upper()):
63
+ found[c] = c.upper()
64
+ return list(found.items())
65
+
66
+
67
+ def describe(c: str, upper: Optional[str] = None) -> str:
68
+ name = unicodedata.name(c, "")
69
+ shown = repr(c) if unicodedata.category(c)[0] != "C" else "a control character"
70
+ what = f"the character {shown} (U+{ord(c):04X}{' ' + name if name else ''})"
71
+ if upper is not None:
72
+ return (f"{what} becomes {upper!r} in the upper-cased name or section title, which pdflatex cannot "
73
+ "print with the fonts of the cv-tools class; leave it out of the name and titles")
74
+ return (f"{what} cannot be printed by pdflatex with the fonts of the cv-tools class; "
75
+ "replace it or leave it out")
76
+
77
+
78
+ def _texts(value: Any, path: YamlPath) -> Iterator[tuple[YamlPath, str]]:
79
+ if isinstance(value, dict):
80
+ if list(value) == ["latex"] and isinstance(value["latex"], str):
81
+ yield path, value["latex"]
82
+ return
83
+ for key, child in value.items():
84
+ if key not in _NOT_TEXT:
85
+ yield from _texts(child, path + (key,))
86
+ elif isinstance(value, list):
87
+ for i, child in enumerate(value):
88
+ yield from _texts(child, path + (i,))
89
+ elif isinstance(value, str):
90
+ yield path, to_latex(value) # URLs in links come out escaped
91
+
92
+
93
+ def _uppercased(path: YamlPath) -> bool:
94
+ return path == ("name",) or (len(path) == 3 and path[0] == "sections" and path[2] == "title")
95
+
96
+
97
+ def _offset(text: str, line: int, col: int) -> int:
98
+ start = 0
99
+ for _ in range(line - 1):
100
+ match = _YAML_LINE_BREAK.search(text, start)
101
+ if match is None:
102
+ return len(text)
103
+ start = match.end()
104
+ return start + col - 1
105
+
106
+
107
+ def _position(text: str, start: int) -> tuple[int, int]:
108
+ breaks = list(_YAML_LINE_BREAK.finditer(text, 0, start))
109
+ line_start = breaks[-1].end() if breaks else 0
110
+ return len(breaks) + 1, start - line_start + 1
111
+
112
+
113
+ def check_data(src: Source, data: dict, source_path: Callable[[YamlPath], YamlPath],
114
+ show_path: bool = True) -> list[CVError]:
115
+ """An error for each character of a text field that pdflatex cannot print."""
116
+ from .validate import format_path
117
+
118
+ errors = []
119
+ for path, latex in _texts(data, ()):
120
+ found = problems(latex, _uppercased(path))
121
+ if not found:
122
+ continue
123
+ where = src.position(source_path(path))
124
+ start = _offset(src.text, *where) if where else 0
125
+ for c, upper in found:
126
+ index = src.text.find(c, start)
127
+ line, col = _position(src.text, index) if index != -1 else (where or (None, None))
128
+ prefix = f"{format_path(path)}: " if show_path else ""
129
+ errors.append(CVError(prefix + describe(c, upper), src.name, line, col))
130
+ return sorted(errors, key=lambda e: (e.line or 0, e.col or 0))
131
+
132
+
133
+ def check_publications(bib_path: Path, publications) -> list[CVError]:
134
+ """An error for each character of a used .bib field that pdflatex cannot print."""
135
+ errors = []
136
+ for pub in publications:
137
+ fields = [("author", a.name) for a in pub.authors]
138
+ fields += [("title", pub.title), (pub.venue_field, pub.venue), ("year", pub.year)]
139
+ for field, value in fields:
140
+ for c, _ in problems(value or ""):
141
+ errors.append(CVError(f"entry '{pub.key}', field '{field}': {describe(c)}", str(bib_path)))
142
+ return errors