Files
nathanwoodburn 1037dba072
Check Code Quality / RuffCheck (push) Successful in 1m25s
Build Docker / BuildImage (push) Successful in 2m38s
feat: Add CLI and other formats for resume
2026-09-01 17:05:57 +10:00

722 lines
21 KiB
Python

import datetime
import json
import os
import re
import shutil
import subprocess
from functools import lru_cache
import jinja2
from dateutil.parser import parse
from flask import Request, jsonify, make_response, render_template
# HTTP status codes
HTTP_OK = 200
HTTP_BAD_REQUEST = 400
HTTP_NOT_FOUND = 404
CRAWLERS = [
"Googlebot",
"Bingbot",
"Chrome-Lighthouse",
"Slurp",
"DuckDuckBot",
"Baiduspider",
"YandexBot",
"Sogou",
"Exabot",
"facebot",
"ia_archiver",
"Twitterbot",
]
CLI_AGENTS = ["curl", "hurl", "xh", "Posting", "HTTPie", "nushell"]
def getClientIP(request: Request) -> str:
"""
Get the client's IP address from the request.
Args:
request (Request): The Flask request object
Returns:
str: The client's IP address
"""
x_forwarded_for = request.headers.get("X-Forwarded-For")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0]
else:
ip = request.remote_addr
if ip is None:
ip = "unknown"
return ip
@lru_cache(maxsize=1)
def getGitCommit() -> str:
"""
Get the current git commit hash.
Returns:
str: The current git commit hash or a failure message
"""
# if .git exists, get the latest commit hash
if os.path.isdir(".git"):
git_dir = ".git"
head_ref = ""
with open(os.path.join(git_dir, "HEAD")) as file:
head_ref = file.read().strip()
if head_ref.startswith("ref: "):
head_ref = head_ref[5:]
with open(os.path.join(git_dir, head_ref)) as file:
return file.read().strip()
else:
return head_ref
# Check if env SOURCE_COMMIT is set
if "SOURCE_COMMIT" in os.environ:
return os.environ["SOURCE_COMMIT"]
return "failed to get version"
def isCLI(request: Request) -> bool:
"""
Check if the request is from curl or hurl.
Args:
request (Request): The Flask request object
Returns:
bool: True if the request is from curl or hurl, False otherwise
"""
if request.headers and request.headers.get("User-Agent"):
user_agent = request.headers.get("User-Agent", "")
return any(agent in user_agent for agent in CLI_AGENTS)
return False
def isFinger(request: Request) -> bool:
"""
Check if the request is a finger request.
Args:
request (Request): The Flask request object
Returns:
bool: True if the request is a finger request, False otherwise
"""
if request.headers and request.headers.get("User-Agent"):
user_agent = request.headers.get("User-Agent", "")
return "finger" in user_agent.lower()
return False
def isCrawler(request: Request) -> bool:
"""
Check if the request is from a web crawler (e.g., Googlebot, Bingbot).
Args:
request (Request): The Flask request object
Returns:
bool: True if the request is from a web crawler, False otherwise
"""
if request.headers and request.headers.get("User-Agent"):
user_agent = request.headers.get("User-Agent", "")
return any(crawler in user_agent for crawler in CRAWLERS)
return False
@lru_cache(maxsize=128)
def isDev(host: str) -> bool:
"""
Check if the host indicates a development environment.
Args:
host (str): The host string from the request
Returns:
bool: True if in development environment, False otherwise
"""
return (
host == "localhost:5000"
or host == "127.0.0.1:5000"
or os.getenv("DEV") == "true"
or host == "test.nathan.woodburn.au"
)
@lru_cache(maxsize=128)
def getHandshakeScript(host: str) -> str:
"""
Get the handshake script HTML snippet.
Args:
host (str): The host string from the request
Returns:
str: The handshake script HTML snippet
"""
return ""
# if isDev(host):
# return ""
# return '<script src="https://nathan.woodburn/handshake.js" domain="nathan.woodburn" async></script><script src="https://nathan.woodburn/https.js" async></script>'
@lru_cache(maxsize=64)
def getAddress(coin: str) -> str:
"""
Get the wallet address for a cryptocurrency.
Args:
coin (str): The cryptocurrency code
Returns:
str: The wallet address or empty string if not found
"""
address = ""
wallet_path = f".well-known/wallets/{coin.upper()}"
if os.path.isfile(wallet_path):
with open(wallet_path) as file:
address = file.read()
return address
@lru_cache(maxsize=256)
def getFilePath(name: str, path: str) -> str | None:
"""
Find a file in a directory tree, searching non-external directories first.
Args:
name (str): The filename to find
path (str): The root directory to search
Returns:
Optional[str]: The full path to the file or None if not found
"""
external_dir = os.path.abspath(os.path.join(path, "assets", "img", "external"))
# 1. Search in non-external directories first
for root, dirs, files in os.walk(path):
root_abs = os.path.abspath(root)
if root_abs == external_dir or root_abs.startswith(external_dir + os.sep):
continue
if name in files:
return os.path.join(root, name)
# 2. Search external directory if not found in any other directory
if os.path.isdir(external_dir):
for root, dirs, files in os.walk(external_dir):
if name in files:
return os.path.join(root, name)
return None
def json_response(
request: Request, message: str | dict = "404 Not Found", code: int = 404
):
"""
Create a JSON response with standard formatting.
Args:
request (Request): The Flask request object
message (Union[str, Dict]): The response message or data
code (int): The HTTP status code
Returns:
Tuple[Dict, int]: The JSON response and HTTP status code
"""
if isinstance(message, dict):
# Add status and ip to dict
message["status"] = code
message["ip"] = getClientIP(request)
return jsonify(message), code
return jsonify(
{
"status": code,
"message": message,
"ip": getClientIP(request),
}
), code
def error_response(
request: Request,
message: str = "404 Not Found",
code: int = 404,
force_json: bool = False,
) -> tuple[dict, int] | object:
"""
Create an error response in JSON or HTML format.
Args:
request (Request): The Flask request object
message (str): The error message
code (int): The HTTP status code
force_json (bool): Whether to force JSON response regardless of client
Returns:
Union[Tuple[Dict, int], object]: The JSON or HTML response
"""
if force_json or isCLI(request):
return json_response(request, message, code)
# Check if <error code>.html exists in templates
template_name = (
f"{code}.html" if os.path.isfile(f"templates/{code}.html") else "404.html"
)
response = make_response(
render_template(template_name, code=code, message=message), code
)
# Add message to response headers
response.headers["X-Error-Message"] = message
return response
def parse_date(date_groups: list[str]) -> str | None:
"""
Parse a list of date components into YYYY-MM-DD format.
Uses dateutil.parser for robust parsing.
Works for:
- DD Month YYYY
- Month DD, YYYY
- YYYY-MM-DD
- YYYYMMDD
- Month YYYY (defaults day to 1)
- Handles ordinal suffixes (st, nd, rd, th)
"""
try:
# Join date groups into a single string
date_str = " ".join(date_groups).strip()
# Remove ordinal suffixes
date_str = re.sub(r"(\d+)(st|nd|rd|th)", r"\1", date_str, flags=re.IGNORECASE)
# Parse with dateutil, default day=1 if missing
dt = parse(date_str, default=datetime.datetime(1900, 1, 1, tzinfo=datetime.UTC))
# If year is missing, parse will fallback to 1900 → reject
if dt.year == 1900:
return None
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
return None
def get_tools_data():
with open("data/tools.json", "r") as f:
return json.load(f)
def find_chromium() -> str | None:
"""Find available chromium/chrome binary for headless PDF generation."""
for cmd in ["chromium", "chromium-browser", "google-chrome", "chrome"]:
path = shutil.which(cmd)
if path:
return path
for p in [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
]:
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return None
def parse_support_arg(req) -> bool:
"""
Check if the support query parameter is set on the request.
Handles ?support, ?support=1, ?support=true, ?support=yes.
"""
if not req or not req.args:
return False
val = req.args.get("support")
if val is None:
return False
if val == "":
return True
return str(val).lower().strip() in ("1", "true", "yes", "support")
def get_resume_data(support: bool = False) -> dict:
"""Load and prepare resume data from data/resume.json."""
with open("data/resume.json", "r", encoding="utf-8") as f:
data = json.load(f)
title = data.get("titles", {}).get(
"support" if support else "default", data.get("title", "")
)
summary = data.get("summaries", {}).get(
"support" if support else "default", data.get("summary", "")
)
return {
**data,
"active_title": title,
"active_summary": summary,
"is_support": support,
}
def get_resume_md(support: bool = False, resume_data: dict | None = None) -> str:
"""Generate a clean Markdown representation of the resume."""
if resume_data is None:
resume_data = get_resume_data(support=support)
lines = []
lines.append(f"# {resume_data.get('name', 'Nathan Woodburn')}")
active_title = resume_data.get("active_title", "")
if active_title:
lines.append(f"### {active_title}\n")
# Contact
contact_parts = []
for item in resume_data.get("contact", []):
label = item.get("label") or ""
url = item.get("url")
if url:
contact_parts.append(f"[{label}]({url})")
elif label:
contact_parts.append(label)
if contact_parts:
lines.append(" • ".join(contact_parts) + "\n")
# Summary
summary = resume_data.get("active_summary", "")
if summary:
lines.append("## Summary\n")
lines.append(f"{summary}\n")
# Experience
experience = resume_data.get("experience", [])
if experience:
lines.append("## Experience\n")
for job in experience:
role = job.get("role", "")
company = job.get("company", "")
location = job.get("location")
dates = job.get("dates")
header_parts = [p for p in [role, company] if p]
lines.append(f"### {' — '.join(header_parts)}")
meta_parts = [p for p in [dates, location] if p]
if meta_parts:
lines.append(f"*{' | '.join(meta_parts)}*")
for bullet in job.get("bullets", []):
lines.append(f"- {bullet}")
lines.append("")
# Projects
projects = resume_data.get("projects", [])
if projects:
lines.append("## Projects\n")
for proj in projects:
name = proj.get("name", "")
tech = proj.get("technologies")
lines.append(f"### {name}")
if tech:
lines.append(f"*Technologies: {tech}*")
for bullet in proj.get("bullets", []):
lines.append(f"- {bullet}")
lines.append("")
# Education
education = resume_data.get("education", [])
if education:
lines.append("## Education\n")
for edu in education:
degree = edu.get("degree", "")
inst = edu.get("institution", "")
dates = edu.get("dates")
desc = edu.get("description")
header_parts = [p for p in [degree, inst] if p]
lines.append(f"### {' — '.join(header_parts)}")
if dates:
lines.append(f"*{dates}*")
if desc:
lines.append(f"- {desc}")
lines.append("")
# Skills
skills = resume_data.get("skills", [])
if skills:
lines.append("## Skills\n")
for skill in skills:
lines.append(f"- {skill}")
lines.append("")
return "\n".join(lines).strip() + "\n"
def get_resume_txt(support: bool = False, resume_data: dict | None = None) -> str:
"""Generate a clean plain text representation of the resume."""
if resume_data is None:
resume_data = get_resume_data(support=support)
divider = "=" * 80
sub_divider = "-" * 80
lines = [divider]
lines.append(resume_data.get("name", "Nathan Woodburn").upper())
active_title = resume_data.get("active_title", "")
if active_title:
lines.append(active_title)
lines.append(divider)
lines.append("")
# Contact
contact = resume_data.get("contact", [])
if contact:
lines.append("CONTACT")
lines.append(sub_divider)
for item in contact:
c_type = (item.get("type") or "Info").capitalize()
label = item.get("label") or ""
url = item.get("url")
val = (
label
if (not url or url.startswith(("tel:", "mailto:")))
else (f"{label} ({url})" if label != url else url)
)
lines.append(f"{c_type + ':':<11} {val}")
lines.append("")
# Summary
summary = resume_data.get("active_summary", "")
if summary:
lines.append("SUMMARY")
lines.append(sub_divider)
lines.append(summary)
lines.append("")
# Experience
experience = resume_data.get("experience", [])
if experience:
lines.append("EXPERIENCE")
lines.append(sub_divider)
for job in experience:
role = job.get("role", "")
company = job.get("company", "")
location = job.get("location")
dates = job.get("dates")
role_company = f"{role} — {company}" if company else role
meta = " | ".join([p for p in [dates, location] if p])
lines.append(role_company)
if meta:
lines.append(meta)
for bullet in job.get("bullets", []):
lines.append(f" * {bullet}")
lines.append("")
# Projects
projects = resume_data.get("projects", [])
if projects:
lines.append("PROJECTS")
lines.append(sub_divider)
for proj in projects:
name = proj.get("name", "")
tech = proj.get("technologies")
if tech:
lines.append(f"{name} ({tech})")
else:
lines.append(name)
for bullet in proj.get("bullets", []):
lines.append(f" * {bullet}")
lines.append("")
# Education
education = resume_data.get("education", [])
if education:
lines.append("EDUCATION")
lines.append(sub_divider)
for edu in education:
degree = edu.get("degree", "")
inst = edu.get("institution", "")
dates = edu.get("dates")
desc = edu.get("description")
deg_inst = f"{degree} — {inst}" if inst else degree
lines.append(deg_inst)
if dates:
lines.append(dates)
if desc:
lines.append(f" * {desc}")
lines.append("")
# Skills
skills = resume_data.get("skills", [])
if skills:
lines.append("SKILLS")
lines.append(sub_divider)
for skill in skills:
lines.append(f" * {skill}")
lines.append("")
lines.append(divider)
return "\n".join(lines).strip() + "\n"
def get_resume_ascii(
support: bool = False,
header: str | None = None,
resume_data: dict | None = None,
) -> str:
"""Generate the CLI ASCII/ANSI terminal representation of the resume."""
if resume_data is None:
resume_data = get_resume_data(support=support)
if header is None:
if os.path.exists("templates/header.ascii"):
with open("templates/header.ascii", "r", encoding="utf-8") as f:
header = f.read()
else:
header = ""
if os.path.exists("templates/resume.ascii"):
with open("templates/resume.ascii", "r", encoding="utf-8") as f:
template_str = f.read()
return jinja2.Template(template_str).render(
header=header, resume=resume_data, support=support
)
return get_resume_txt(support=support, resume_data=resume_data)
def get_resume_source_mtime() -> float:
"""Get the latest modification timestamp of resume templates, data, and styling assets."""
source_files = [
"data/resume.json",
"templates/resume.html",
"templates/resume.ascii",
"templates/assets/css/resume-print.css",
"templates/assets/css/resume-custom.css",
"templates/assets/css/resume.min.css",
"templates/assets/css/styles.min.css",
"templates/assets/bootstrap/css/bootstrap.min.css",
"templates/assets/img/nathanwoodburn.jpeg",
]
mtimes = [os.path.getmtime(f) for f in source_files if os.path.exists(f)]
return max(mtimes) if mtimes else 0.0
def build_resume_pdf(support: bool = False, output_path: str | None = None) -> str:
"""
Build the resume PDF from templates/resume.html and CSS assets using headless Chromium.
Returns the path to the generated PDF.
"""
target_pdf = output_path or (
"data/resume_support.pdf" if support else "data/resume.pdf"
)
chromium_bin = find_chromium()
if not chromium_bin:
return target_pdf
os.makedirs(os.path.dirname(os.path.abspath(target_pdf)), exist_ok=True)
with open("templates/resume.html", "r", encoding="utf-8") as f:
html = f.read()
css_parts = []
for css_rel in [
"templates/assets/bootstrap/css/bootstrap.min.css",
"templates/assets/css/styles.min.css",
"templates/assets/css/resume.min.css",
"templates/assets/css/resume-custom.css",
"templates/assets/css/resume-print.css",
]:
if os.path.exists(css_rel):
with open(css_rel, "r", encoding="utf-8") as cf:
css_parts.append(cf.read())
resume_data = get_resume_data(support=support)
rendered = jinja2.Template(html).render(resume=resume_data, support=support)
rendered = rendered.replace(
"</head>",
f"<style>\n{chr(10).join(css_parts)}\n</style>\n</head>",
)
img_path = os.path.abspath("templates/assets/img/nathanwoodburn.jpeg")
rendered = rendered.replace("/assets/img/nathanwoodburn.jpeg", f"file://{img_path}")
tmp_html = f"data/.tmp_resume_{'support' if support else 'main'}.html"
with open(tmp_html, "w", encoding="utf-8") as f:
f.write(rendered)
try:
subprocess.run(
[
chromium_bin,
"--headless",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
"--allow-file-access-from-files",
"--enable-local-file-accesses",
"--no-pdf-header-footer",
f"--print-to-pdf={os.path.abspath(target_pdf)}",
f"file://{os.path.abspath(tmp_html)}",
],
check=True,
timeout=30,
capture_output=True,
)
except (subprocess.SubprocessError, OSError) as e:
print(f"Warning: Failed to build resume PDF via Chromium: {e}")
finally:
if os.path.exists(tmp_html):
try:
os.remove(tmp_html)
except OSError:
pass
return target_pdf
def get_resume_pdf(support: bool = False, force: bool = False) -> str:
"""
Get the resume PDF path, automatically rebuilding it if source files were updated.
"""
target_pdf = "data/resume_support.pdf" if support else "data/resume.pdf"
needs_build = (
force
or not os.path.exists(target_pdf)
or os.path.getmtime(target_pdf) < get_resume_source_mtime()
)
if needs_build:
build_resume_pdf(support=support, output_path=target_pdf)
return target_pdf
if __name__ == "__main__":
import sys
support = "--support" in sys.argv or "-s" in sys.argv
if "--json" in sys.argv:
print(json.dumps(get_resume_data(support=support), indent=2))
elif "--md" in sys.argv or "--markdown" in sys.argv:
print(get_resume_md(support=support))
elif "--txt" in sys.argv or "--text" in sys.argv:
print(get_resume_txt(support=support))
elif "--ascii" in sys.argv or "--cli" in sys.argv:
print(get_resume_ascii(support=support))
elif "--build-resume" in sys.argv or "-b" in sys.argv or len(sys.argv) == 1:
print("Building standard resume PDF...")
p1 = build_resume_pdf(support=False)
print(f"Built {p1} ({os.path.getsize(p1)} bytes)")
print("Building technical support resume PDF...")
p2 = build_resume_pdf(support=True)
print(f"Built {p2} ({os.path.getsize(p2)} bytes)")
print("Resume PDF build finished.")