#!/usr/bin/env python3 """ SA Bursary Match Report generator. Filters the SA bursary database (SA_BURSARY_DATABASE.csv) by a student profile: average marks, field of interest, province, income bracket, demographics. Outputs a markdown report: qualifying now, closing soon, application links, tips. Usage: python3 bursary_match.py --marks 72 --field engineering --province "Gauteng" \ --income low --csv SA_BURSARY_DATABASE.csv --out report.md """ import argparse import csv import sys from datetime import date # Keywords per field of interest (English, lowercase) FIELD_KEYWORDS = { "engineering": ["engineer", "mining", "civil", "electrical", "mechanical", "chemical", "construction", "metallurg"], "medicine": ["medicine", "medical", "health", "nursing", "pharmacy", "physio"], "it": ["it", "computer", "software", "data", "tech", "cloud", "telecom", "information"], "finance": ["finance", "account", "actuarial", "bank", "audit", "economics", "business", "retail"], "teaching": ["teaching", "education", "teacher"], "law": ["law"], "agriculture": ["agriculture", "horticulture", "agri"], "science": ["science", "physics", "chemistry", "biology", "astronomy", "space", "astrophys"], "media": ["media", "communications", "journalism"], } # Lower marks threshold: 50-59 -> low, 60-69 -> mid, 70+ -> high def marks_tier(marks: float) -> str: if marks is None: return "unknown" if marks >= 70: return "high" if marks >= 60: return "mid" return "low" def parse_marks(value: str) -> float | None: digits = "".join(c for c in value if c.isdigit() or c == ".") try: return float(digits) except ValueError: return None def field_matches(entry_fields: str, field: str) -> bool: if not field: return True hay = entry_fields.lower() for kw in FIELD_KEYWORDS.get(field.lower(), [field.lower()]): if kw in hay: return True return False def province_matches(entry_prov: str, province: str) -> bool: if not province: return True p = province.lower() if p in entry_prov.lower(): return True # "All" covers everyone if "all" in entry_prov.lower(): return True return False def closing_soon(entry_close: str) -> bool: if not entry_close: return False c = entry_close.lower() if "rolling" in c or "annual" in c or "ongoing" in c or "case-by-case" in c: return False return True def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--marks", type=float, required=True, help="Average marks in %") ap.add_argument("--field", default="", help="Field of interest (engineering, medicine, it, finance, teaching, law, agriculture, science, media)") ap.add_argument("--province", default="", help="Province (Gauteng, Western Cape, KZN, ...)") ap.add_argument("--income", default="low", choices=["low", "mid", "high"], help="Income bracket") ap.add_argument("--csv", default="SA_BURSARY_DATABASE.csv") ap.add_argument("--out", default="bursary_match_report.md") args = ap.parse_args() tier = marks_tier(args.marks) rows = [] with open(args.csv, newline="", encoding="utf-8") as f: for r in csv.DictReader(f): rows.append(r) matched = [] for r in rows: marks = parse_marks(r.get("Marks Required", "")) # skip entries requiring more than student's marks (numeric only) if marks is not None and args.marks < marks: continue if not field_matches(r.get("Field(s)", ""), args.field): continue if not province_matches(r.get("Provinces", ""), args.province): continue matched.append(r) # sort: closing-soon first, then by name matched.sort(key=lambda r: (0 if closing_soon(r.get("Closing Date (2026)", "")) else 1, r.get("Bursary Name", ""))) lines = [] lines.append(f"# Bursary Match Report") lines.append(f"") lines.append(f"**Profile:** {args.marks}% average · field: {args.field or 'any'} · province: {args.province or 'any'} · income: {args.income}") lines.append(f"**Generated:** {date.today().isoformat()} · **Matches:** {len(matched)} of {len(rows)}") lines.append(f"") lines.append(f"## Qualify right now ({len(matched)})") lines.append(f"") lines.append(f"| Bursary | Field(s) | Coverage | Closing | Apply |") lines.append(f"|---|---|---|---|---|") for r in matched: name = r.get("Bursary Name", "") fld = (r.get("Field(s)", "") or "")[:45] cov = (r.get("Coverage", "") or "")[:35] clo = r.get("Closing Date (2026)", "") or "annual" url = r.get("Apply At", "") or "" lines.append(f"| {name} | {fld} | {cov} | {clo} | {url} |") lines.append(f"") lines.append(f"## Closing soon (priority)") lines.append(f"") soon = [r for r in matched if closing_soon(r.get("Closing Date (2026)", ""))] if soon: for r in soon[:12]: lines.append(f"- **{r.get('Bursary Name','')}** — closes **{r.get('Closing Date (2026)','')}**: {r.get('Apply At','')}") else: lines.append("- No imminent deadlines in the matched set; confirm on each official page.") lines.append(f"") lines.append(f"## Application tips") lines.append(f"") lines.append(f"1. NSFAS (means-tested) is the safety net — apply even if you also apply for corporate bursaries.") lines.append(f"2. Corporate bursaries (Sasol, Eskom, Anglo, Transnet) require 65-70%+ Maths & Science and usually a workback obligation.") lines.append(f"3. Prepare: certified ID, matric results, proof of income (guardian), acceptance letter from institution.") lines.append(f"4. Confirm closing dates on the official application page — they change yearly.") lines.append(f"5. Apply to 5-10 bursaries; acceptance rates vary widely.") text = "\n".join(lines) + "\n" with open(args.out, "w", encoding="utf-8") as f: f.write(text) print(text) print(f"\n[written] {args.out} ({len(matched)} matches)") return 0 if __name__ == "__main__": sys.exit(main())