#!/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: