diff --git a/tools/generate_cv.py b/tools/generate_cv.py new file mode 100755 index 0000000..4c26987 --- /dev/null +++ b/tools/generate_cv.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Generate role-tailored LaTeX CVs from documents/sources/ YAML. + +Layout matches the two-column paracol examples in documents/cv/ +(PradNukala_StaffSoftwareEngineer_Circle.tex et al.). Everything factual +is pulled from documents/sources/: + +- Contact block <- about/profile.yml +- Honors / Organizations <- about/honors.yml, about/organizations.yml +- Education <- about/education.yml +- Experience entries <- experience/.yml (title, company, dates, + location, and description bullets) +- Project entries <- projects/.yml (title, dates, bullets) +- Skills <- skills/*.yml (whole groups via `from:`, or + explicit items validated against the sources) + +A role config (documents/cv/roles/.yml) selects and arranges that +material. Experience/project bullets are either `use: N` (1-based index +into the source's description bullets, verbatim) or a literal string (a +condensed rewrite; must stay grounded in the same source). + +Usage: + python3 tools/generate_cv.py --list + python3 tools/generate_cv.py staff-software-engineer + python3 tools/generate_cv.py --all + python3 tools/generate_cv.py --output cv/custom_name.tex + +Dates are emitted with a single ASCII hyphen ("Mar 2021 - Present"), not +"--": LaTeX ligatures "--" into an en-dash that breaks ATS date-range +parsing (see 05-cv-templates.md, "Date fields must be ASCII ranges"). +""" + +import argparse +import sys +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parent.parent +SOURCES = REPO / "documents" / "sources" +ROLES_DIR = REPO / "documents" / "cv" / "roles" +OUT_DIR = REPO / "cv" + +MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +] + +# Multi-char substitutions applied before single-char escaping. +_UNICODE_SUBS = [ + ("\u2014", "---"), # em dash + ("\u2013", "--"), # en dash + ("\u201c", "``"), # left double quote + ("\u201d", "''"), # right double quote + ("\u2018", "`"), # left single quote + ("\u2019", "'"), # right single quote + ("\u2026", "\\ldots{}"), + ("\u00b7", "\\textperiodcentered{}"), + ("\u0113", "\\=e"), # ē (Yuē) - not in T1-encoded fonts +] + +_CHAR_ESCAPES = { + "&": "\\&", + "%": "\\%", + "$": "\\$", + "#": "\\#", + "_": "\\_", + "~": "\\textasciitilde{}", + "^": "\\textasciicircum{}", +} + + +class ConfigError(Exception): + """Raised when a role config or source file is unusable.""" + + +def latex_escape(text): + """Escape plain text (from YAML) for use in LaTeX body text.""" + text = str(text).strip() + for src, dst in _UNICODE_SUBS: + text = text.replace(src, dst) + return "".join(_CHAR_ESCAPES.get(ch, ch) for ch in text) + + +def load_yaml(path): + try: + with open(path, encoding="utf-8") as handle: + return yaml.safe_load(handle) + except FileNotFoundError as exc: + raise ConfigError(f"missing source file: {path}") from exc + except yaml.YAMLError as exc: + raise ConfigError(f"invalid YAML in {path}: {exc}") from exc + + +def parse_bullets(description): + """Split a description block scalar into individual bullets. + + Lines starting with "- " open a new bullet; other non-blank lines + continue the current one. Leading prose before the first "- " (see + projects/ecosystem-devrel.yml) becomes its own bullet. + """ + bullets = [] + current = [] + for line in (description or "").splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("- "): + if current: + bullets.append(" ".join(current)) + current = [stripped[2:].strip()] + else: + current.append(stripped) + if current: + bullets.append(" ".join(current)) + return bullets + + +def format_month_year(value): + return f"{MONTHS[value.month - 1]} {value.year}" + + +def format_range(start, end, month=True): + """Render a date range with an ASCII hyphen (ATS-parseable).""" + fmt = format_month_year if month else (lambda d: str(d.year)) + left = fmt(start) if hasattr(start, "year") else str(start) + right = "Present" if end is None else (fmt(end) if hasattr(end, "year") else str(end)) + return f"{left} - {right}" + + +def _skill_aliases(name): + """Known spellings for one canonical skill name. + + "Decentralized Identifiers (DIDs)" also answers to "Decentralized + Identifiers" and "DIDs", so tailored labels match the sources. + """ + name = name.strip() + aliases = {name} + if name.endswith(")") and "(" in name: + base, paren = name.rsplit("(", 1) + aliases.add(base.strip()) + aliases.add(paren.rstrip(")").strip()) + return {alias.casefold() for alias in aliases if alias} + + +def known_skills(): + """Union of every skill named anywhere under documents/sources/.""" + known = set() + + def add(names): + for name in names or []: + known.update(_skill_aliases(str(name))) + + languages = load_yaml(SOURCES / "skills" / "languages.yml") + for tier in languages.values(): + add(tier) + add(load_yaml(SOURCES / "skills" / "frameworks.yml")) + add(load_yaml(SOURCES / "skills" / "methodology.yml")) + for folder in ("experience", "projects"): + for path in sorted((SOURCES / folder).glob("*.yml")): + add(load_yaml(path).get("skills")) + add((load_yaml(SOURCES / "about" / "education.yml")).get("skills")) + return known + + +def validate_skills(groups, extra, warn): + known = known_skills() + for name in extra: + known.update(_skill_aliases(str(name))) + for group in groups: + for item in group["items"]: + if not _skill_aliases(item) & known: + warn(f"skill not found in documents/sources/: {item!r}") + + +def resolve_skill_groups(config, warn): + """Expand the config's skills section. + + Each group is {group: