Files

502 lines
18 KiB
Python
Executable File

#!/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/<name>.yml (title, company, dates,
location, and description bullets)
- Project entries <- projects/<name>.yml (title, dates, bullets)
- Skills <- skills/*.yml (whole groups via `from:`, or
explicit items validated against the sources)
A role config (documents/cv/roles/<role>.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 <role> --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: <label>, items: [...]} and/or
{group: <label>, from: <selector>} where <selector> is one of
"languages", "languages.<tier>", "frameworks", "methodology".
A top-level `extra-skills:` list allowlists tailored labels that the
sources do not spell out verbatim (e.g. "EVM", "Microservices").
"""
groups = []
for entry in config.get("skills", []):
label = entry.get("group")
if not label:
raise ConfigError(f"skills group missing 'group' label: {entry!r}")
items = list(entry.get("items") or [])
selector = entry.get("from")
if selector:
parts = selector.split(".")
data = load_yaml(SOURCES / "skills" / f"{parts[0]}.yml")
if len(parts) == 2:
if not isinstance(data, dict) or parts[1] not in data:
raise ConfigError(f"unknown skills selector: {selector!r}")
items.extend(data[parts[1]])
elif isinstance(data, list):
items.extend(data)
else:
raise ConfigError(
f"selector {selector!r} needs a tier (e.g. languages.expert)"
)
if not items:
raise ConfigError(f"skills group {label!r} resolved to no items")
groups.append({"group": label, "items": [str(i) for i in items]})
validate_skills(groups, config.get("extra-skills") or [], warn)
return groups
def resolve_bullets(entry, source, source_path):
"""Resolve a config entry's bullets against its source description."""
pool = parse_bullets(source.get("description"))
spec = entry.get("bullets", "all")
if spec == "all":
return list(pool)
resolved = []
for item in spec:
if isinstance(item, str):
resolved.append(item)
elif isinstance(item, dict) and "use" in item:
index = item["use"]
if not isinstance(index, int) or not 1 <= index <= len(pool):
raise ConfigError(
f"{source_path.name}: bullet index {index!r} out of range "
f"(source has {len(pool)} bullets)"
)
resolved.append(pool[index - 1])
else:
raise ConfigError(f"bad bullet spec {item!r} (string or {{use: N}})")
if not resolved:
raise ConfigError(f"{source_path.name}: entry resolved to no bullets")
return resolved
def resolve_experience(config):
entries = []
for entry in config.get("experience", []):
path = SOURCES / "experience" / f"{entry['source']}.yml"
source = load_yaml(path)
entries.append({
"title": entry.get("title", source["title"]),
"company": source["company"],
"location": source.get("location", ""),
"dates": format_range(source["start"], source.get("end")),
"bullets": resolve_bullets(entry, source, path),
})
return entries
def resolve_projects(config):
entries = []
for entry in config.get("projects", []):
path = SOURCES / "projects" / f"{entry['source']}.yml"
source = load_yaml(path)
dates = entry.get("dates") or format_range(
source["start"], source.get("end"), month=False
)
entries.append({
"title": entry.get("title", source["title"]),
"dates": dates,
"bullets": resolve_bullets(entry, source, path),
})
return entries
def resolve_education(config):
source = load_yaml(SOURCES / "about" / "education.yml")
title = config.get("education-title") or f"{source['degree']} in {source['field']}"
return {
"title": title,
"school": source["school"],
"dates": format_range(source["start"], source["end"], month=False),
}
def _sidebar_block(heading, body_lines):
lines = [f"{{\\bfseries {heading}}}\\\\[2pt]", "\\rule{\\linewidth}{0.4pt}\\\\[4pt]"]
lines.extend(body_lines)
return lines
def _main_heading(heading):
return [f"{{\\bfseries {heading}}}\\\\[2pt]", "\\rule{\\linewidth}{0.4pt}\\\\[4pt]"]
def _itemize(bullets):
lines = ["\\begin{itemize}[leftmargin=*, nosep, topsep=2pt]"]
lines.extend(f" \\item \\small{{{latex_escape(b)}}}" for b in bullets)
lines.append("\\end{itemize}")
return lines
def render(config, warn):
profile = load_yaml(SOURCES / "about" / "profile.yml")
honors = load_yaml(SOURCES / "about" / "honors.yml") or []
organizations = load_yaml(SOURCES / "about" / "organizations.yml") or []
skills = resolve_skill_groups(config, warn)
experience = resolve_experience(config)
projects = resolve_projects(config)
education = resolve_education(config)
name = latex_escape(profile["full-name"])
email = latex_escape(profile["email"])
out = [
f"%% {config.get('description', 'Role-tailored CV')}",
"%% GENERATED by tools/generate_cv.py - do not edit by hand.",
f"%% Role config: documents/cv/roles/{config['_name']}.yml",
"%% Facts: documents/sources/ (regenerate after editing either).",
"%% Compile with: lualatex <file>.tex",
"",
"\\documentclass[11pt]{article}",
"\\usepackage[utf8]{inputenc}",
"\\usepackage[T1]{fontenc}",
"\\usepackage{geometry}",
"\\usepackage{enumitem}",
"\\usepackage{paracol}",
"\\usepackage[hidelinks]{hyperref}",
"\\geometry{a4paper, margin=0.5in}",
"\\setlength{\\parindent}{0pt}",
"\\setlength{\\parskip}{2pt}",
"\\columnratio{0.3}",
"",
"\\begin{document}",
"",
"{\\centering",
f" {{\\Huge \\textbf{{{name}}}}}\\\\[2pt]",
f" {{\\large {latex_escape(config['headline'])}}}",
" \\par",
"}",
"\\vspace{14pt}",
"",
"\\begin{paracol}{2}",
"\\raggedright",
"\\small",
]
# --- Sidebar: Contact ---
contact = [f"\\href{{mailto:{profile['email']}}}{{{email}}}\\\\[2pt]"]
contact.append(f"{latex_escape(profile['phone'])}\\\\[2pt]")
for key, label in (
("website", "Website"),
("linkedin", "LinkedIn"),
("github", "GitHub"),
("twitter", "Twitter"),
):
if profile.get(key):
contact.append(f"\\href{{{profile[key]}}}{{{label}}}\\\\[2pt]")
contact[-1] = contact[-1].removesuffix("\\\\[2pt]")
out.extend(_sidebar_block("Contact", contact))
out.append("")
out.append("\\vspace{8pt}")
out.append("")
# --- Sidebar: Skills ---
skill_lines = []
for i, group in enumerate(skills):
gap = "[4pt]" if i == len(skills) - 1 else "[5pt]"
skill_lines.append(f"\\textbf{{{latex_escape(group['group'])}}}\\\\[1pt]")
items = ", ".join(latex_escape(item) for item in group["items"])
skill_lines.append(f"{items}\\\\{gap}")
out.extend(_sidebar_block("Skills", skill_lines))
out.append("")
out.append("\\vspace{8pt}")
out.append("")
# --- Sidebar: Honors ---
if honors:
honor_lines = []
for honor in honors:
honor_lines.append(f"\\textbf{{{latex_escape(honor['title'])}}}\\\\[1pt]")
year = honor["date"].year if hasattr(honor.get("date"), "year") else honor.get("date", "")
honor_lines.append(
f"{latex_escape(honor['institution'])} \\hfill \\textit{{{year}}}\\\\[4pt]"
)
out.extend(_sidebar_block("Honors", honor_lines))
out.append("")
out.append("\\vspace{8pt}")
out.append("")
# --- Sidebar: Organizations ---
if organizations:
org_lines = []
for i, org in enumerate(organizations):
gap = "[2pt]" if i == len(organizations) - 1 else "[4pt]"
org_lines.append(f"\\textbf{{{latex_escape(org['name'])}}}\\\\[1pt]")
org_lines.append(f"{latex_escape(org['title'])}\\\\{gap}")
out.extend(_sidebar_block("Organizations", org_lines))
out.append("")
out.append("\\vspace{8pt}")
out.append("")
out.append("")
out.append("\\switchcolumn")
out.append("\\normalsize")
out.append("\\raggedright")
# --- Main: Experience ---
out.extend(_main_heading("Experience"))
for i, job in enumerate(experience):
out.append(
f"\\textbf{{{latex_escape(job['title'])}}} \\hfill "
f"\\textit{{{job['dates']}}}\\\\"
)
location = latex_escape(job["location"])
company = latex_escape(job["company"])
out.append(f"\\textit{{{company} --- {location}}}" if location else f"\\textit{{{company}}}")
out.extend(_itemize(job["bullets"]))
out.append("\\vspace{6pt}" if i == len(experience) - 1 else "\\vspace{4pt}")
out.append("")
# --- Main: Projects ---
if projects:
out.extend(_main_heading("Projects"))
for i, project in enumerate(projects):
out.append(
f"\\textbf{{{latex_escape(project['title'])}}} \\hfill "
f"\\textit{{{latex_escape(project['dates'])}}}"
)
out.extend(_itemize(project["bullets"]))
out.append("\\vspace{6pt}" if i == len(projects) - 1 else "\\vspace{4pt}")
out.append("")
# --- Main: Publications (optional, literal entries in the role config) ---
publications = config.get("publications", [])
if publications:
out.extend(_main_heading("Publications \\& Talks"))
out.append("\\begin{itemize}[leftmargin=*, nosep, topsep=2pt]")
for pub in publications:
text = latex_escape(pub["text"])
if pub.get("url"):
text += f" \\href{{{pub['url']}}}{{[link]}}"
out.append(f" \\item \\small{{{text}}}")
out.append("\\end{itemize}")
out.append("\\vspace{6pt}")
out.append("")
# --- Main: Education ---
out.extend(_main_heading("Education"))
out.append(
f"\\textbf{{{latex_escape(education['title'])}}} \\hfill "
f"\\textit{{{education['dates']}}}\\\\"
)
out.append(f"\\textit{{{latex_escape(education['school'])}}}")
out.append("\\vspace{4pt}")
out.extend(["", "", "\\end{paracol}", "", "\\end{document}", ""])
return "\n".join(out)
def list_roles():
return sorted(path.stem for path in ROLES_DIR.glob("*.yml"))
def generate(role, output=None, warn=None):
warn = warn or (lambda msg: print(f"warning: {role}: {msg}", file=sys.stderr))
path = ROLES_DIR / f"{role}.yml"
config = load_yaml(path)
if not isinstance(config, dict) or "headline" not in config:
raise ConfigError(f"{path}: role config needs at least a 'headline'")
config["_name"] = role
target = Path(output) if output else OUT_DIR / config.get("output", f"main_example_{role.replace('-', '_')}.tex")
target.write_text(render(config, warn), encoding="utf-8")
return target
def build_parser():
parser = argparse.ArgumentParser(
description="Generate role-tailored LaTeX CVs from documents/sources/."
)
parser.add_argument("roles", nargs="*", help="role config names (documents/cv/roles/<name>.yml)")
parser.add_argument("--all", action="store_true", help="generate every role config")
parser.add_argument("--list", action="store_true", help="list available role configs")
parser.add_argument("--output", help="explicit output path (single role only)")
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
if args.list:
for role in list_roles():
print(role)
return 0
roles = list_roles() if args.all else args.roles
if not roles:
build_parser().error("give one or more roles, --all, or --list")
if args.output and len(roles) != 1:
build_parser().error("--output only makes sense with exactly one role")
try:
for role in roles:
target = generate(role, output=args.output)
print(f"wrote {target.relative_to(REPO)}")
except ConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())