cvcite 0.2.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.
- cvcite/__init__.py +4 -0
- cvcite/__main__.py +5 -0
- cvcite/_rendercv_shim.py +54 -0
- cvcite/checks.py +379 -0
- cvcite/cli.py +225 -0
- cvcite/compare.py +79 -0
- cvcite/config.py +123 -0
- cvcite/doctor.py +54 -0
- cvcite/gate.py +421 -0
- cvcite/ingest.py +240 -0
- cvcite/overlay.py +195 -0
- cvcite/package.py +64 -0
- cvcite/render.py +112 -0
- cvcite/scaffold.py +98 -0
- cvcite/templates/repo/AGENTS.md +70 -0
- cvcite/templates/repo/cv.yaml +57 -0
- cvcite/templates/repo/cvcite.toml +25 -0
- cvcite/templates/repo/facts.yaml +18 -0
- cvcite/templates/repo/github/workflows/cv-review.yml +148 -0
- cvcite/templates/repo/github/workflows/cv.yml +36 -0
- cvcite/templates/repo/github/workflows/release.yml +79 -0
- cvcite/templates/repo/gitignore +22 -0
- cvcite/templates/repo/variants/general.yaml +19 -0
- cvcite/templates/sample/cv.yaml +89 -0
- cvcite/templates/sample/cvcite.toml +25 -0
- cvcite/templates/sample/facts.yaml +63 -0
- cvcite/templates/sample/variants/manager.yaml +28 -0
- cvcite/templates/sample/variants/staff-engineer.yaml +23 -0
- cvcite/vendor/fontawesome-0.6.0/LICENSE +21 -0
- cvcite/vendor/fontawesome-0.6.0/lib-gen-func.typ +5266 -0
- cvcite/vendor/fontawesome-0.6.0/lib-gen-map.typ +5259 -0
- cvcite/vendor/fontawesome-0.6.0/lib-impl.typ +90 -0
- cvcite/vendor/fontawesome-0.6.0/lib.typ +79 -0
- cvcite/vendor/fontawesome-0.6.0/typst.toml +8 -0
- cvcite-0.2.0.dist-info/METADATA +178 -0
- cvcite-0.2.0.dist-info/RECORD +39 -0
- cvcite-0.2.0.dist-info/WHEEL +4 -0
- cvcite-0.2.0.dist-info/entry_points.txt +2 -0
- cvcite-0.2.0.dist-info/licenses/LICENSE +21 -0
cvcite/__init__.py
ADDED
cvcite/__main__.py
ADDED
cvcite/_rendercv_shim.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Run RenderCV's own command line against the Typst package folder cvcite prepared, with its
|
|
2
|
+
network access turned off.
|
|
3
|
+
|
|
4
|
+
Usage: python -P -m cvcite._rendercv_shim <package-folder> render <rendercv arguments>
|
|
5
|
+
|
|
6
|
+
RenderCV hands Typst a package folder holding only its own template, and that template imports an
|
|
7
|
+
icon package from the network on every render, into a cache in the home directory. cvcite's
|
|
8
|
+
folder holds the template (ligatures and kerning off) and the icon package (vendored). Replacing
|
|
9
|
+
`get_package_path` before the first compile is all it takes: the compiler looks it up when it is
|
|
10
|
+
created.
|
|
11
|
+
|
|
12
|
+
RenderCV's command line also checks PyPI for a newer release on every run, in a background thread,
|
|
13
|
+
with TLS verification turned off, and caches the answer in the home directory. cvcite pins the
|
|
14
|
+
version anyway, so the check is replaced with nothing. With both in place a render makes no
|
|
15
|
+
network request and writes nothing outside the build.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import pathlib
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apply_patches(package_folder: pathlib.Path) -> None:
|
|
25
|
+
"""Point RenderCV at cvcite's package folder and switch off its update check. Fails closed:
|
|
26
|
+
on a RenderCV without these functions, assigning them would do nothing and the render would
|
|
27
|
+
quietly fetch the icon package, keep ligatures on and call PyPI."""
|
|
28
|
+
from importlib.metadata import version
|
|
29
|
+
|
|
30
|
+
from rendercv.cli import app
|
|
31
|
+
from rendercv.renderer import pdf_png
|
|
32
|
+
|
|
33
|
+
if version("rendercv") != "2.8":
|
|
34
|
+
sys.exit(f"cvcite: RenderCV {version('rendercv')} is installed; this cvcite release "
|
|
35
|
+
"supports RenderCV 2.8 only")
|
|
36
|
+
|
|
37
|
+
for module, name in ((pdf_png, "get_package_path"), (app, "warn_if_new_version_is_available")):
|
|
38
|
+
if not callable(getattr(module, name, None)):
|
|
39
|
+
sys.exit(f"cvcite: RenderCV changed shape (no {module.__name__}.{name}); this cvcite "
|
|
40
|
+
"release supports RenderCV 2.8 only")
|
|
41
|
+
pdf_png.get_package_path = lambda: package_folder
|
|
42
|
+
app.warn_if_new_version_is_available = lambda: None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> int:
|
|
46
|
+
apply_patches(pathlib.Path(sys.argv.pop(1)).resolve())
|
|
47
|
+
from rendercv.cli.entry_point import entry_point
|
|
48
|
+
|
|
49
|
+
sys.argv[0] = "rendercv"
|
|
50
|
+
return entry_point() or 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
cvcite/checks.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Checks that need no render: dates, the RenderCV traps, banned characters, skills backed by
|
|
2
|
+
bullets, every number traced to the fact ledger, and privacy. Each returns `Issue`s; an error
|
|
3
|
+
fails `cvcite check` and `cvcite build`, a warning is shown and passes."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .overlay import skill_items, skills_section
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Issue:
|
|
16
|
+
level: str # "error" or "warning"
|
|
17
|
+
where: str
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
return f"{self.level.upper():<7} {self.where}: {self.message}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def entries(cv: dict):
|
|
25
|
+
"""Yield (where, section name, entry) for every entry that is a mapping."""
|
|
26
|
+
for name, items in ((cv or {}).get("sections") or {}).items():
|
|
27
|
+
for i, entry in enumerate(items or []):
|
|
28
|
+
if isinstance(entry, dict):
|
|
29
|
+
yield f"cv.sections.{name}[{i}]", str(name), entry
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def strings(cv: dict):
|
|
33
|
+
"""Yield (where, text) for every string in the `cv` mapping."""
|
|
34
|
+
def walk(node, where):
|
|
35
|
+
if isinstance(node, dict):
|
|
36
|
+
for key, value in node.items():
|
|
37
|
+
yield from walk(value, f"{where}.{key}")
|
|
38
|
+
elif isinstance(node, list):
|
|
39
|
+
for i, item in enumerate(node):
|
|
40
|
+
yield from walk(item, f"{where}[{i}]")
|
|
41
|
+
elif isinstance(node, str):
|
|
42
|
+
yield where, node
|
|
43
|
+
yield from walk(cv or {}, "cv")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def prose(cv: dict):
|
|
47
|
+
"""Yield (where, text) for every bullet, entry summary and plain text entry (a summary
|
|
48
|
+
paragraph, a certification line): the claims a reader reads."""
|
|
49
|
+
for name, items in ((cv or {}).get("sections") or {}).items():
|
|
50
|
+
for i, entry in enumerate(items or []):
|
|
51
|
+
where = f"cv.sections.{name}[{i}]"
|
|
52
|
+
if isinstance(entry, str):
|
|
53
|
+
yield where, entry
|
|
54
|
+
elif isinstance(entry, dict):
|
|
55
|
+
for j, bullet in enumerate(entry.get("highlights") or []):
|
|
56
|
+
yield f"{where}.highlights[{j}]", str(bullet)
|
|
57
|
+
if isinstance(entry.get("summary"), str):
|
|
58
|
+
yield f"{where}.summary", entry["summary"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------------------------------ dates
|
|
62
|
+
|
|
63
|
+
DATE_RE = re.compile(r"^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_date(value) -> tuple[int, int] | None | str:
|
|
67
|
+
"""(year, month) for a RenderCV date, "present" for the present, None when unparseable.
|
|
68
|
+
A bare year counts as its January for ordering, and its December as an end date."""
|
|
69
|
+
if isinstance(value, int):
|
|
70
|
+
return (value, 0)
|
|
71
|
+
if isinstance(value, dt.date):
|
|
72
|
+
return (value.year, value.month)
|
|
73
|
+
text = str(value).strip().lower()
|
|
74
|
+
if text == "present":
|
|
75
|
+
return "present"
|
|
76
|
+
m = DATE_RE.match(text)
|
|
77
|
+
if not m:
|
|
78
|
+
return None
|
|
79
|
+
return (int(m.group(1)), int(m.group(2)) if m.group(2) else 0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_dates(cv: dict, today: dt.date | None = None) -> list[Issue]:
|
|
83
|
+
today = today or dt.date.today()
|
|
84
|
+
now = (today.year, today.month)
|
|
85
|
+
issues: list[Issue] = []
|
|
86
|
+
spans: dict[str, list[tuple[str, tuple, tuple, str]]] = {}
|
|
87
|
+
for where, section, entry in entries(cv):
|
|
88
|
+
start, end = entry.get("start_date"), entry.get("end_date")
|
|
89
|
+
if start is None and end is None:
|
|
90
|
+
continue
|
|
91
|
+
for key, raw in (("start_date", start), ("end_date", end)):
|
|
92
|
+
if raw is None:
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(raw, str) and re.fullmatch(r"\d{4}", raw.strip()):
|
|
95
|
+
issues.append(Issue("warning", f"{where}.{key}",
|
|
96
|
+
f"the year {raw} is quoted; RenderCV may print it as "
|
|
97
|
+
"January of that year. Write it unquoted"))
|
|
98
|
+
parsed = parse_date(raw)
|
|
99
|
+
if parsed is None:
|
|
100
|
+
issues.append(Issue("error", f"{where}.{key}",
|
|
101
|
+
f"'{raw}' is not YYYY, YYYY-MM, YYYY-MM-DD or present"))
|
|
102
|
+
elif parsed != "present" and parsed > now:
|
|
103
|
+
issues.append(Issue("error", f"{where}.{key}", f"{raw} is in the future"))
|
|
104
|
+
s, e = parse_date(start) if start is not None else None, parse_date(end)
|
|
105
|
+
if isinstance(s, tuple):
|
|
106
|
+
e_cmp = now if e in (None, "present") else e
|
|
107
|
+
if isinstance(e_cmp, tuple):
|
|
108
|
+
if (s[0], s[1] or 1) > (e_cmp[0], e_cmp[1] or 12):
|
|
109
|
+
issues.append(Issue("error", where, f"starts ({start}) after it ends ({end})"))
|
|
110
|
+
company = str(entry.get("company") or entry.get("institution") or "")
|
|
111
|
+
spans.setdefault(section, []).append((where, s, e_cmp, company))
|
|
112
|
+
for section, items in spans.items():
|
|
113
|
+
starts = [(s[0], s[1] or 1) for _, s, _, _ in items]
|
|
114
|
+
if starts != sorted(starts, reverse=True):
|
|
115
|
+
issues.append(Issue("warning", f"cv.sections.{section}",
|
|
116
|
+
"entries are not newest first"))
|
|
117
|
+
ordered = sorted(items, key=lambda t: (t[1][0], t[1][1] or 1))
|
|
118
|
+
for (w1, _, e1, c1), (w2, s2, _, c2) in zip(ordered, ordered[1:]):
|
|
119
|
+
# A year-only date says nothing about the month: "2018" then "2018-06" may touch,
|
|
120
|
+
# overlap or leave a gap. Compare months only when both sides name one.
|
|
121
|
+
if not e1[1] or not s2[1]:
|
|
122
|
+
months = 12 * (s2[0] - e1[0]) if s2[0] != e1[0] else 0
|
|
123
|
+
else:
|
|
124
|
+
months = (s2[0] - e1[0]) * 12 + (s2[1] - e1[1])
|
|
125
|
+
if months < 0 and c1 != c2:
|
|
126
|
+
issues.append(Issue("warning", w2, f"overlaps {w1} by {-months} month(s); "
|
|
127
|
+
"fine for concurrent roles, but say so in the text"))
|
|
128
|
+
elif months > 6 and section.lower() in ("experience", "work", "employment"):
|
|
129
|
+
issues.append(Issue("warning", w2, f"{months} months after {w1} ended: "
|
|
130
|
+
"a reader will ask about the gap"))
|
|
131
|
+
return issues
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------------- text traps
|
|
135
|
+
|
|
136
|
+
def check_text(cv: dict, settings: dict | None, banned: tuple[str, ...]) -> list[Issue]:
|
|
137
|
+
issues: list[Issue] = []
|
|
138
|
+
for where, text in strings(cv):
|
|
139
|
+
if re.search(r"#[A-Za-z_][\w-]{0,40}\s{0,3}[(\[]", text[:2000]):
|
|
140
|
+
issues.append(Issue("error", where, "holds Typst code (#name( or #name[): RenderCV "
|
|
141
|
+
"would run it, and it can read files into the PDF"))
|
|
142
|
+
if "TODO" in text:
|
|
143
|
+
issues.append(Issue("error", where, "still holds a TODO: fill it in from the fact "
|
|
144
|
+
"ledger, or take it out"))
|
|
145
|
+
for ch in banned:
|
|
146
|
+
if ch in text:
|
|
147
|
+
issues.append(Issue("error", where, f"contains the banned character "
|
|
148
|
+
f"U+{ord(ch):04X}: use a colon, a comma or parentheses"))
|
|
149
|
+
for where, text in prose(cv):
|
|
150
|
+
if " - " in text and where.split(".")[-1].startswith("highlights"):
|
|
151
|
+
issues.append(Issue("error", where, "' - ' inside a bullet: RenderCV splits it into "
|
|
152
|
+
"two bullets. Use a colon or a comma"))
|
|
153
|
+
keywords = [str(k) for k in ((settings or {}).get("bold_keywords") or [])]
|
|
154
|
+
for where, _, entry in entries(cv):
|
|
155
|
+
for key in ("position", "company", "institution", "degree", "area", "title", "name"):
|
|
156
|
+
value = entry.get(key)
|
|
157
|
+
if isinstance(value, str):
|
|
158
|
+
for kw in keywords:
|
|
159
|
+
if kw and kw in value:
|
|
160
|
+
issues.append(Issue("warning", f"{where}.{key}",
|
|
161
|
+
f"the bold keyword '{kw}' is in this field, where "
|
|
162
|
+
"its asterisks can show up literally"))
|
|
163
|
+
return issues
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------------- skills and the ledger
|
|
167
|
+
|
|
168
|
+
def skill_parts(item: str) -> list[str]:
|
|
169
|
+
"""The names an item stands for: "Spark/PySpark (MLlib)" stands for Spark, PySpark, MLlib."""
|
|
170
|
+
return [p.strip() for p in re.split(r"[/()]", item) if p.strip()]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_skills_backed(cv: dict, section: str = "Skills") -> list[Issue]:
|
|
174
|
+
"""Every skill on the skills line should appear in a bullet or summary of the same CV."""
|
|
175
|
+
text = " ".join(t for _, t in prose(cv)).lower()
|
|
176
|
+
issues = []
|
|
177
|
+
for label, items in skill_items(skills_section(cv, section)).items():
|
|
178
|
+
for item in items:
|
|
179
|
+
if not any(re.search(rf"(?<!\w){re.escape(p.lower())}(?!\w)", text)
|
|
180
|
+
for p in skill_parts(item)):
|
|
181
|
+
issues.append(Issue("warning", f"cv.sections.{section} ({label})",
|
|
182
|
+
f"'{item}' appears in no bullet or summary: back it with "
|
|
183
|
+
"one, or drop it from this skills line"))
|
|
184
|
+
return issues
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
NUMBER_RE = re.compile(r"(?<![\w.])(?:[$€£]\s?)?\d[\d,.]*(?:\s?(?:%|k|K|M|B|x))?(?![\w])")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def numbers(text: str) -> set[str]:
|
|
191
|
+
"""Numeric claims in a text, normalised: "40 %" and "40%" are the same claim."""
|
|
192
|
+
return {re.sub(r"\s+", "", m).rstrip(".,") for m in NUMBER_RE.findall(text)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
VISIBILITIES = ("public", "cv-only", "private")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check_ledger(facts: list[dict] | None) -> list[Issue]:
|
|
199
|
+
"""The ledger itself: every fact names a known visibility. A misspelt one ("Private",
|
|
200
|
+
"privat") must not quietly turn a private fact into a public one."""
|
|
201
|
+
issues = []
|
|
202
|
+
for i, fact in enumerate(facts or []):
|
|
203
|
+
value = fact.get("visibility")
|
|
204
|
+
if value not in VISIBILITIES:
|
|
205
|
+
issues.append(Issue("error", f"facts[{i}] ({fact.get('id', '?')})",
|
|
206
|
+
f"visibility is {value!r}: it must be one of "
|
|
207
|
+
f"{', '.join(VISIBILITIES)}"))
|
|
208
|
+
terms = fact.get("terms", [])
|
|
209
|
+
if not isinstance(terms, list) or not all(isinstance(t, str) for t in terms):
|
|
210
|
+
issues.append(Issue("error", f"facts[{i}] ({fact.get('id', '?')})",
|
|
211
|
+
"terms must be a list of strings"))
|
|
212
|
+
return issues
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_numbers_in_ledger(cv: dict, facts: list[dict] | None) -> list[Issue]:
|
|
216
|
+
"""Every number in a bullet or summary must appear in a fact of the ledger. A figure no fact
|
|
217
|
+
holds was estimated, inferred or remembered, which is how CVs end up overclaiming. Nothing
|
|
218
|
+
from a `visibility: private` fact may reach the CV: neither a number only it holds, nor any
|
|
219
|
+
of the identifying `terms` it lists (a customer under NDA, an internal project name)."""
|
|
220
|
+
if facts is None:
|
|
221
|
+
return [Issue("warning", "facts.yaml", "no fact ledger: numbers in bullets go unchecked")]
|
|
222
|
+
issues = check_ledger(facts)
|
|
223
|
+
shareable: set[str] = set()
|
|
224
|
+
private: set[str] = set()
|
|
225
|
+
private_terms: set[str] = set()
|
|
226
|
+
for fact in facts:
|
|
227
|
+
found = numbers(str(fact.get("text", "")))
|
|
228
|
+
if fact.get("visibility") == "public" or fact.get("visibility") == "cv-only":
|
|
229
|
+
shareable |= found
|
|
230
|
+
else: # private, or a visibility check_ledger already rejected: fail closed
|
|
231
|
+
private |= found
|
|
232
|
+
private_terms |= {t for t in fact.get("terms") or [] if isinstance(t, str) and t}
|
|
233
|
+
for where, text in prose(cv):
|
|
234
|
+
for number in sorted(numbers(text) - shareable):
|
|
235
|
+
if re.fullmatch(r"(19|20)\d\d", number):
|
|
236
|
+
continue # a year is checked by the date checks
|
|
237
|
+
if number in private:
|
|
238
|
+
issues.append(Issue("error", where, f"'{number}' comes from a private fact of "
|
|
239
|
+
"the ledger and must not be on the CV"))
|
|
240
|
+
else:
|
|
241
|
+
issues.append(Issue("error", where, f"'{number}' is in no fact of the ledger: "
|
|
242
|
+
"add the fact with its source first, or take the number out"))
|
|
243
|
+
for where, text in strings(cv):
|
|
244
|
+
for term in sorted(private_terms):
|
|
245
|
+
if term.lower() in text.lower():
|
|
246
|
+
issues.append(Issue("error", where, f"'{term}' is a term of a private fact and "
|
|
247
|
+
"must not be on the CV"))
|
|
248
|
+
return issues
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------------------------- privacy
|
|
252
|
+
|
|
253
|
+
PRIVATE_FIELDS = {
|
|
254
|
+
"photo": "a photo",
|
|
255
|
+
"date_of_birth": "a date of birth",
|
|
256
|
+
"birth_date": "a date of birth",
|
|
257
|
+
"birthday": "a date of birth",
|
|
258
|
+
"nationality": "a nationality",
|
|
259
|
+
"marital_status": "a marital status",
|
|
260
|
+
}
|
|
261
|
+
# Personal details a CV in these markets leaves out, as labels in the languages CVs are most often
|
|
262
|
+
# written in. A label with its value ("Date of birth: ...") is an error. The same words in running
|
|
263
|
+
# text ("built the passport scanning service") only warn: they may describe the work.
|
|
264
|
+
PRIVATE_LABELS = {
|
|
265
|
+
"a date of birth": [
|
|
266
|
+
"date of birth", "birth date", "birthdate", "born on", "dob", "d.o.b.", "born", "birthday",
|
|
267
|
+
"age", "place of birth", "geburtsdatum", "geboren am", "geb.", "alter",
|
|
268
|
+
"fecha de nacimiento", "date de naissance", "data di nascita", "data de nascimento",
|
|
269
|
+
"geboortedatum", "data urodzenia"],
|
|
270
|
+
"a marital status": [
|
|
271
|
+
"marital status", "familienstand", "estado civil", "situation familiale", "état civil",
|
|
272
|
+
"stato civile", "burgerlijke staat", "stan cywilny"],
|
|
273
|
+
"a nationality": [
|
|
274
|
+
"nationality", "citizenship", "citizen of", "staatsangehörigkeit", "nationalität",
|
|
275
|
+
"nacionalidad", "nationalité",
|
|
276
|
+
"nazionalità", "nacionalidade", "nationaliteit", "obywatelstwo"],
|
|
277
|
+
"an identity, tax or bank number": [
|
|
278
|
+
"passport number", "passport", "id card number", "identity card", "personalausweis",
|
|
279
|
+
"national id", "social security number", "account number", "routing number", "swift", "bic",
|
|
280
|
+
"national insurance number", "tax id", "steuer-id", "codice fiscale", "sort code"],
|
|
281
|
+
}
|
|
282
|
+
# Labels that are ordinary words in running text ("born and raised", "Swift", "age of"): they count
|
|
283
|
+
# only as a label with a value, never warn on their own.
|
|
284
|
+
LABEL_ONLY = {"born", "birthday", "age", "alter", "geb.", "d.o.b.", "dob", "passport", "swift",
|
|
285
|
+
"bic", "citizen of"}
|
|
286
|
+
# Short identity and bank codes count only next to a value ("NIE: X1234567L", "CPF 123.456"),
|
|
287
|
+
# since as bare words several are ordinary words in some language.
|
|
288
|
+
ID_CODES = ["ssn", "nino", "dni", "nie", "nif", "cpf", "cbu", "bsn", "nir", "pesel",
|
|
289
|
+
"curp", "rut", "iban", "svnr"]
|
|
290
|
+
PRIVATE_PATTERNS = [
|
|
291
|
+
(r"wa\.me/", "a WhatsApp link", 0),
|
|
292
|
+
(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}\b", "what looks like an IBAN", 0),
|
|
293
|
+
(r"\b\d{3}-\d{2}-\d{4}\b", "what looks like a US Social Security number", 0),
|
|
294
|
+
(r"\b(" + "|".join(ID_CODES) + r")\b\s*:?\s*[A-Z]?[\d][\d.\- /]{4,}", "an identity, tax or "
|
|
295
|
+
"bank number", re.I),
|
|
296
|
+
]
|
|
297
|
+
STREET_RE = re.compile(
|
|
298
|
+
r"\d{1,5}\s{0,3}[a-z]{0,30}\s{0,3}(street|st\.|avenue|ave\.|road|rd\.|lane|drive|"
|
|
299
|
+
r"stra(ss|ß)e|weg|platz|calle|avenida|rue|via|viale|rua|straat|laan|ulica)\b"
|
|
300
|
+
r"|\b(street|stra(ss|ß)e|weg|platz|allee|gasse|calle|avenida|rue|via|rua|straat|laan|ulica)"
|
|
301
|
+
r"\s{1,3}\d{1,5}"
|
|
302
|
+
r"|\w{1,30}(stra(ss|ß)e|weg|platz|allee|gasse|straat|laan)\s{0,3}\d{1,5}", re.I)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def check_privacy(cv: dict, market: str = "international") -> list[Issue]:
|
|
306
|
+
"""What a CV in the international and US markets leaves out: photo, date of birth,
|
|
307
|
+
nationality, marital status, a street address, identity and bank numbers."""
|
|
308
|
+
issues = []
|
|
309
|
+
for key, what in PRIVATE_FIELDS.items():
|
|
310
|
+
if (cv or {}).get(key):
|
|
311
|
+
issues.append(Issue("error", f"cv.{key}", f"{what}: leave it out; it invites bias "
|
|
312
|
+
f"and no {market} employer needs it to shortlist you"))
|
|
313
|
+
labelled = {}
|
|
314
|
+
for where, _, entry in entries(cv):
|
|
315
|
+
if isinstance(entry.get("label"), str) and "details" in entry:
|
|
316
|
+
labelled[f"{where}.label"] = entry["label"].strip().rstrip(":").lower()
|
|
317
|
+
for where, value in labelled.items():
|
|
318
|
+
for what, labels in PRIVATE_LABELS.items():
|
|
319
|
+
if value in labels:
|
|
320
|
+
issues.append(Issue("error", where, f"{what}: leave it out of the CV"))
|
|
321
|
+
for where, full in strings(cv):
|
|
322
|
+
text = full[:2000] # the patterns below need no more, and stay linear on hostile input
|
|
323
|
+
for what, labels in PRIVATE_LABELS.items():
|
|
324
|
+
for label in labels:
|
|
325
|
+
if re.search(rf"(?<!\w){re.escape(label)}\s*:", text, re.I):
|
|
326
|
+
issues.append(Issue("error", where, f"{what}: leave it out of the CV"))
|
|
327
|
+
elif label not in LABEL_ONLY and re.search(rf"(?<!\w){re.escape(label)}(?!\w)",
|
|
328
|
+
text, re.I):
|
|
329
|
+
issues.append(Issue("warning", where, f"mentions '{label}': fine if it "
|
|
330
|
+
"describes the work, never your own "
|
|
331
|
+
+ what.split(" ", 1)[1]))
|
|
332
|
+
for pattern, what, flags in PRIVATE_PATTERNS:
|
|
333
|
+
if re.search(pattern, text, flags):
|
|
334
|
+
issues.append(Issue("error", where, f"{what}: leave it out of the CV"))
|
|
335
|
+
location = str((cv or {}).get("location") or "")
|
|
336
|
+
if STREET_RE.search(location) or re.search(r"\d", location):
|
|
337
|
+
issues.append(Issue("error", "cv.location",
|
|
338
|
+
"looks like a street address or postcode: give the city and "
|
|
339
|
+
"country only"))
|
|
340
|
+
for where, text in strings(cv):
|
|
341
|
+
if where != "cv.location" and STREET_RE.search(text[:2000]):
|
|
342
|
+
issues.append(Issue("warning", where, "looks like a street address: fine for an "
|
|
343
|
+
"employer's office only if you mean it, never your own"))
|
|
344
|
+
return issues
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# RenderCV 2.8's built-in themes. A theme name that is not one of them makes RenderCV import a
|
|
348
|
+
# folder of that name next to the YAML and run its __init__.py: code from the repository.
|
|
349
|
+
BUILT_IN_THEMES = {"classic", "ember", "engineeringclassic", "engineeringresumes", "harvard",
|
|
350
|
+
"ink", "moderncv", "opal", "sb2nov"}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def check_render_settings(merged: dict) -> list[Issue]:
|
|
354
|
+
"""Settings that would make a render run code from the repository or move its files: a custom
|
|
355
|
+
theme, RenderCV's own render command (output paths) and rendercv_settings. cvcite sets the
|
|
356
|
+
paths itself."""
|
|
357
|
+
issues = []
|
|
358
|
+
theme = str((merged.get("design") or {}).get("theme", "classic"))
|
|
359
|
+
if theme not in BUILT_IN_THEMES:
|
|
360
|
+
issues.append(Issue("error", "design.theme", f"'{theme}' is not a built-in RenderCV "
|
|
361
|
+
f"theme ({', '.join(sorted(BUILT_IN_THEMES))}): custom themes run "
|
|
362
|
+
"code from the repository, so cvcite does not render them"))
|
|
363
|
+
if "rendercv_settings" in merged:
|
|
364
|
+
issues.append(Issue("error", "rendercv_settings", "rendercv_settings is not "
|
|
365
|
+
"supported: cvcite sets the render paths itself"))
|
|
366
|
+
if "render_command" in (merged.get("settings") or {}):
|
|
367
|
+
issues.append(Issue("error", "settings.render_command", "settings.render_command is "
|
|
368
|
+
"not supported: cvcite sets the render paths itself"))
|
|
369
|
+
return issues
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def run_all(merged: dict, *, banned: tuple[str, ...], facts: list[dict] | None,
|
|
373
|
+
market: str, skills: str = "Skills",
|
|
374
|
+
today: dt.date | None = None) -> list[Issue]:
|
|
375
|
+
cv = merged.get("cv") or {}
|
|
376
|
+
return (check_render_settings(merged)
|
|
377
|
+
+ check_dates(cv, today) + check_text(cv, merged.get("settings"), banned)
|
|
378
|
+
+ check_skills_backed(cv, skills) + check_numbers_in_ledger(cv, facts)
|
|
379
|
+
+ check_privacy(cv, market))
|
cvcite/cli.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The `cvcite` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__, checks, config, gate, overlay
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_facts(cfg: config.Config) -> list[dict] | None:
|
|
13
|
+
"""The fact ledger: facts.yaml, plus facts.private.yaml when it exists. The private file is
|
|
14
|
+
git-ignored, so facts kept there are never committed, pushed or sent to a CI reviewer."""
|
|
15
|
+
if not cfg.facts_path.exists():
|
|
16
|
+
return None
|
|
17
|
+
facts = []
|
|
18
|
+
private_file = cfg.facts_path.with_name(cfg.facts_path.stem + ".private.yaml")
|
|
19
|
+
for path in (cfg.facts_path, private_file):
|
|
20
|
+
if path.exists():
|
|
21
|
+
data = overlay.load_yaml(path) or {}
|
|
22
|
+
facts += [dict(f) for f in (data.get("facts") or []) if isinstance(f, dict)]
|
|
23
|
+
return facts
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def variants_or_fail(cfg: config.Config, wanted: list[str]) -> list[Path]:
|
|
27
|
+
overlays = cfg.overlays()
|
|
28
|
+
if wanted:
|
|
29
|
+
overlays = [o for o in overlays if o.stem in wanted]
|
|
30
|
+
if not overlays:
|
|
31
|
+
raise SystemExit(f"no overlay{' matching ' + str(wanted) if wanted else ''} in "
|
|
32
|
+
f"{cfg.variants}/: add one, for example variants/general.yaml")
|
|
33
|
+
return overlays
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_issues(issues: list[checks.Issue]) -> int:
|
|
37
|
+
for issue in issues:
|
|
38
|
+
print(f" {issue}")
|
|
39
|
+
return sum(1 for i in issues if i.level == "error")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_check(args) -> int:
|
|
43
|
+
cfg = config.load(Path(args.dir))
|
|
44
|
+
if not cfg.master_path.exists():
|
|
45
|
+
print(f"no master at {cfg.master_path}: run `cvcite init` first")
|
|
46
|
+
return 1
|
|
47
|
+
facts = load_facts(cfg)
|
|
48
|
+
errors = 0
|
|
49
|
+
master = overlay.load_yaml(cfg.master_path)
|
|
50
|
+
print(f"{cfg.master}:")
|
|
51
|
+
errors += print_issues(checks.run_all(master, banned=cfg.banned, facts=facts,
|
|
52
|
+
market=cfg.market, skills=cfg.skills_section))
|
|
53
|
+
for path in variants_or_fail(cfg, args.variant):
|
|
54
|
+
v = overlay.build_variant(cfg.master_path, path, cfg.skills_section)
|
|
55
|
+
print(f"{cfg.variants}/{path.name}:")
|
|
56
|
+
issues = [checks.Issue("error", path.name, e) for e in v.errors]
|
|
57
|
+
issues += [checks.Issue("warning", path.name, w) for w in v.warnings]
|
|
58
|
+
issues += checks.run_all(v.data, banned=cfg.banned, facts=facts, market=cfg.market,
|
|
59
|
+
skills=cfg.skills_section)
|
|
60
|
+
errors += print_issues(issues)
|
|
61
|
+
print("CHECK FAILED" if errors else "CHECK PASSED", f"({errors} error(s))" if errors else "")
|
|
62
|
+
return 1 if errors else 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_build(args) -> int:
|
|
66
|
+
from . import render
|
|
67
|
+
|
|
68
|
+
cfg = config.load(Path(args.dir))
|
|
69
|
+
facts = load_facts(cfg)
|
|
70
|
+
failed = False
|
|
71
|
+
for path in variants_or_fail(cfg, args.variant):
|
|
72
|
+
v = overlay.build_variant(cfg.master_path, path, cfg.skills_section)
|
|
73
|
+
issues = [checks.Issue("error", path.name, e) for e in v.errors]
|
|
74
|
+
issues += [checks.Issue("warning", path.name, w) for w in v.warnings]
|
|
75
|
+
issues += checks.run_all(v.data, banned=cfg.banned, facts=facts, market=cfg.market,
|
|
76
|
+
skills=cfg.skills_section)
|
|
77
|
+
if print_issues(issues):
|
|
78
|
+
print(f" {v.name}: CHECK FAILED, not rendered")
|
|
79
|
+
failed = True
|
|
80
|
+
continue
|
|
81
|
+
merged_path = cfg.build_path / f"cv-{v.name}.yaml"
|
|
82
|
+
v.dump(merged_path, cfg.master, path.name)
|
|
83
|
+
try:
|
|
84
|
+
pdf = render.render(merged_path, cfg.build_path / v.name, cfg.send_name,
|
|
85
|
+
cfg.root / ".tmp" / "render" / v.name)
|
|
86
|
+
except render.RenderError as exc:
|
|
87
|
+
print(f" {v.name}: RENDER FAILED\n{exc}")
|
|
88
|
+
failed = True
|
|
89
|
+
continue
|
|
90
|
+
result = gate.run_gate(pdf, v.data.get("cv"), extractors=cfg.extractors or None,
|
|
91
|
+
banned=cfg.banned, min_contrast=cfg.min_contrast)
|
|
92
|
+
verdict = "GATE PASSED" if result.passed else "GATE FAILED"
|
|
93
|
+
print(f" {v.name:<16} {result.pages} page(s) {verdict}")
|
|
94
|
+
if not result.passed or args.verbose:
|
|
95
|
+
print("\n".join(" " + line for line in result.report().splitlines()))
|
|
96
|
+
if result.pages != cfg.pages:
|
|
97
|
+
print(f" ^ expected {cfg.pages} page(s); cut bullets with _omit or tighten text")
|
|
98
|
+
failed = True
|
|
99
|
+
failed |= not result.passed
|
|
100
|
+
if not failed:
|
|
101
|
+
print(f" send: {cfg.build_dir}/<variant>/{cfg.send_name}")
|
|
102
|
+
return 1 if failed else 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_gate(args) -> int:
|
|
106
|
+
cfg = config.load(Path(args.dir))
|
|
107
|
+
cv = None
|
|
108
|
+
if args.source:
|
|
109
|
+
cv = (overlay.load_yaml(Path(args.source)) or {}).get("cv")
|
|
110
|
+
result = gate.run_gate(Path(args.pdf), cv, extractors=cfg.extractors or None,
|
|
111
|
+
banned=cfg.banned, min_contrast=cfg.min_contrast)
|
|
112
|
+
print(result.report())
|
|
113
|
+
return 0 if result.passed else 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_package(args) -> int:
|
|
117
|
+
from .package import package
|
|
118
|
+
|
|
119
|
+
ok, messages = package(config.load(Path(args.dir)))
|
|
120
|
+
print("\n".join(messages))
|
|
121
|
+
return 0 if ok else 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_ingest(args) -> int:
|
|
125
|
+
from .ingest import ingest
|
|
126
|
+
|
|
127
|
+
status = 0
|
|
128
|
+
for name in args.files:
|
|
129
|
+
path = Path(name)
|
|
130
|
+
try:
|
|
131
|
+
kind, content = ingest(path)
|
|
132
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
133
|
+
print(f"{path}: {exc}", file=sys.stderr)
|
|
134
|
+
status = 1
|
|
135
|
+
continue
|
|
136
|
+
if args.out:
|
|
137
|
+
out = Path(args.out) / f"{path.stem}.{'json' if kind == 'json' else 'txt'}"
|
|
138
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
out.write_text(content, encoding="utf-8")
|
|
140
|
+
print(f"{path} -> {out}")
|
|
141
|
+
else:
|
|
142
|
+
print(content)
|
|
143
|
+
return status
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_compare(args) -> int:
|
|
147
|
+
from .compare import compare_roles
|
|
148
|
+
from .ingest import linkedin_export
|
|
149
|
+
|
|
150
|
+
cfg = config.load(Path(args.dir))
|
|
151
|
+
cv = (overlay.load_yaml(cfg.master_path) or {}).get("cv") or {}
|
|
152
|
+
rows = compare_roles(cv, linkedin_export(Path(args.linkedin)))
|
|
153
|
+
width = max((len(r.company) for r in rows), default=10)
|
|
154
|
+
for r in rows:
|
|
155
|
+
print(f" {r.status:<17} {r.company:<{width}} {r.detail}")
|
|
156
|
+
bad = sum(r.status != "match" for r in rows)
|
|
157
|
+
print(f"{len(rows) - bad} of {len(rows)} role(s) match" + (f"; {bad} to fix" if bad else ""))
|
|
158
|
+
return 1 if bad else 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_init(args) -> int:
|
|
162
|
+
from .scaffold import init
|
|
163
|
+
|
|
164
|
+
return init(Path(args.target or args.dir), sample=args.sample, force=args.force,
|
|
165
|
+
allow_unpinned=args.allow_unpinned)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_doctor(args) -> int:
|
|
169
|
+
from .doctor import doctor
|
|
170
|
+
|
|
171
|
+
return doctor(Path(args.dir))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(argv: list[str] | None = None) -> int:
|
|
175
|
+
parser = argparse.ArgumentParser(prog="cvcite", description=(
|
|
176
|
+
"Build a CV from one master YAML file and a small overlay per target role, and gate "
|
|
177
|
+
"every PDF the way applicant tracking systems read it."))
|
|
178
|
+
parser.add_argument("--version", action="version", version=f"cvcite {__version__}")
|
|
179
|
+
parser.add_argument("-C", "--dir", default=".", help="the CV repository (default: here)")
|
|
180
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
181
|
+
|
|
182
|
+
p = sub.add_parser("init", help="create a CV repository in a new folder (default: here)")
|
|
183
|
+
p.add_argument("target", nargs="?", help="the folder to create")
|
|
184
|
+
p.add_argument("--sample", action="store_true", help="start from the made-up sample CV")
|
|
185
|
+
p.add_argument("--force", action="store_true", help="write into a folder that has files")
|
|
186
|
+
p.add_argument("--allow-unpinned", action="store_true",
|
|
187
|
+
help="write the workflows even if the action cannot be pinned to a commit")
|
|
188
|
+
p.set_defaults(func=cmd_init)
|
|
189
|
+
|
|
190
|
+
p = sub.add_parser("check", help="dates, claims and privacy, without rendering")
|
|
191
|
+
p.add_argument("variant", nargs="*", help="only these variants")
|
|
192
|
+
p.set_defaults(func=cmd_check)
|
|
193
|
+
|
|
194
|
+
p = sub.add_parser("build", help="check, render and gate every variant")
|
|
195
|
+
p.add_argument("variant", nargs="*", help="only these variants")
|
|
196
|
+
p.add_argument("-v", "--verbose", action="store_true", help="print the full gate report")
|
|
197
|
+
p.set_defaults(func=cmd_build)
|
|
198
|
+
|
|
199
|
+
p = sub.add_parser("gate", help="gate one PDF")
|
|
200
|
+
p.add_argument("pdf")
|
|
201
|
+
p.add_argument("source", nargs="?", help="the merged YAML it was rendered from")
|
|
202
|
+
p.set_defaults(func=cmd_gate)
|
|
203
|
+
|
|
204
|
+
p = sub.add_parser("package", help="zip the build folder with checksums for a release")
|
|
205
|
+
p.set_defaults(func=cmd_package)
|
|
206
|
+
|
|
207
|
+
p = sub.add_parser("doctor", help="check the toolchain")
|
|
208
|
+
p.set_defaults(func=cmd_doctor)
|
|
209
|
+
|
|
210
|
+
p = sub.add_parser("ingest", help="text or data out of an existing CV, a LinkedIn data "
|
|
211
|
+
"export or a JSON Resume")
|
|
212
|
+
p.add_argument("files", nargs="+")
|
|
213
|
+
p.add_argument("--out", help="write one file per input into this folder (default: print)")
|
|
214
|
+
p.set_defaults(func=cmd_ingest)
|
|
215
|
+
|
|
216
|
+
p = sub.add_parser("compare", help="the CV's roles against a LinkedIn data export")
|
|
217
|
+
p.add_argument("--linkedin", required=True, help="the export ZIP, or its unpacked folder")
|
|
218
|
+
p.set_defaults(func=cmd_compare)
|
|
219
|
+
|
|
220
|
+
args = parser.parse_args(argv)
|
|
221
|
+
return args.func(args)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
sys.exit(main())
|