mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-02 13:12:49 +00:00
501 lines
18 KiB
Python
501 lines
18 KiB
Python
#!/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())
|