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.
Files changed (39) hide show
  1. cvcite/__init__.py +4 -0
  2. cvcite/__main__.py +5 -0
  3. cvcite/_rendercv_shim.py +54 -0
  4. cvcite/checks.py +379 -0
  5. cvcite/cli.py +225 -0
  6. cvcite/compare.py +79 -0
  7. cvcite/config.py +123 -0
  8. cvcite/doctor.py +54 -0
  9. cvcite/gate.py +421 -0
  10. cvcite/ingest.py +240 -0
  11. cvcite/overlay.py +195 -0
  12. cvcite/package.py +64 -0
  13. cvcite/render.py +112 -0
  14. cvcite/scaffold.py +98 -0
  15. cvcite/templates/repo/AGENTS.md +70 -0
  16. cvcite/templates/repo/cv.yaml +57 -0
  17. cvcite/templates/repo/cvcite.toml +25 -0
  18. cvcite/templates/repo/facts.yaml +18 -0
  19. cvcite/templates/repo/github/workflows/cv-review.yml +148 -0
  20. cvcite/templates/repo/github/workflows/cv.yml +36 -0
  21. cvcite/templates/repo/github/workflows/release.yml +79 -0
  22. cvcite/templates/repo/gitignore +22 -0
  23. cvcite/templates/repo/variants/general.yaml +19 -0
  24. cvcite/templates/sample/cv.yaml +89 -0
  25. cvcite/templates/sample/cvcite.toml +25 -0
  26. cvcite/templates/sample/facts.yaml +63 -0
  27. cvcite/templates/sample/variants/manager.yaml +28 -0
  28. cvcite/templates/sample/variants/staff-engineer.yaml +23 -0
  29. cvcite/vendor/fontawesome-0.6.0/LICENSE +21 -0
  30. cvcite/vendor/fontawesome-0.6.0/lib-gen-func.typ +5266 -0
  31. cvcite/vendor/fontawesome-0.6.0/lib-gen-map.typ +5259 -0
  32. cvcite/vendor/fontawesome-0.6.0/lib-impl.typ +90 -0
  33. cvcite/vendor/fontawesome-0.6.0/lib.typ +79 -0
  34. cvcite/vendor/fontawesome-0.6.0/typst.toml +8 -0
  35. cvcite-0.2.0.dist-info/METADATA +178 -0
  36. cvcite-0.2.0.dist-info/RECORD +39 -0
  37. cvcite-0.2.0.dist-info/WHEEL +4 -0
  38. cvcite-0.2.0.dist-info/entry_points.txt +2 -0
  39. cvcite-0.2.0.dist-info/licenses/LICENSE +21 -0
cvcite/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """cvcite: build a CV from one master YAML file and an overlay per target role, and gate
2
+ every PDF the way applicant tracking systems read it."""
3
+
4
+ __version__ = "0.2.0"
cvcite/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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())