[{"data":1,"prerenderedAt":2435},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook":3,"surround:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook":2402,"ancestors:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook":2410,"children:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook":2414,"related:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook":2415},{"id":4,"title":5,"body":6,"dateModified":2371,"datePublished":2371,"description":2372,"extension":2373,"faq":2374,"keyword":2383,"kind":2384,"meta":2385,"navigation":121,"path":2393,"seo":2394,"slug":2396,"stem":2397,"topics":2398,"type":2400,"__hash__":2401},"docs\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook\u002Findex.md","Block Large or Sensitive Excel Files with a Pre-commit Hook",{"type":7,"value":8,"toc":2360},"minimark",[9,24,29,57,75,79,1437,1444,1451,1602,1606,1694,1717,1721,1981,1995,2135,2139,2150,2215,2222,2226,2277,2281,2292,2296,2302,2308,2317,2323,2327,2356],[10,11,12,13,17,18,23],"p",{},"Repositories for Excel automation attract spreadsheets. Someone copies the real export into ",[14,15,16],"code",{},"tests\u002F"," to reproduce a bug, saves the output of a run next to the script, or commits the 80 MB extract that the report reads. Once committed, the file is in the history for good — deleting it in a later commit does not remove it from the clones that already exist — and if it holds customer emails, card numbers or payroll data, the repository has become a place where personal data lives, with everything that implies. A pre-commit hook stops that at the source: before each commit, it checks the spreadsheets being committed and refuses the commit if any of them is too large or contains data that looks real. This page writes such a hook in Python, wires it up with the pre-commit framework and tests it in a scratch repository. It belongs to ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002F","Testing and Packaging Excel Automation Scripts",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pre-commit openpyxl\n","bash","",[14,37,38],{"__ignoreMap":35},[39,40,43,47,51,54],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"sMTad","pip",[39,48,50],{"class":49},"srMev"," install",[39,52,53],{"class":49}," pre-commit",[39,55,56],{"class":49}," openpyxl\n",[10,58,59,60,66,67,70,71,74],{},"The hook is a plain Python script in the repository, run by the ",[19,61,65],{"href":62,"rel":63},"https:\u002F\u002Fpre-commit.com",[64],"nofollow","pre-commit"," framework, which installs it as a git hook and passes it the names of the staged files that match a pattern. It works for ",[14,68,69],{},".xlsx"," and ",[14,72,73],{},".xlsm"," workbooks and for CSV files.",[25,76,78],{"id":77},"step-1-the-checker-script","Step 1 — The checker script",[30,80,84],{"className":81,"code":82,"language":83,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","\"\"\"Refuse to commit spreadsheets that are too large or look like they contain real personal data.\"\"\"\nimport csv, re, sys\nfrom pathlib import Path\n\nMAX_BYTES = 2 * 1024 * 1024\nALLOWLIST_FILE = Path(\".spreadsheet-allowlist\")\nPATTERNS = {\n    \"email address\": re.compile(r\"[\\w.+-]+@[\\w-]+\\.[\\w.-]+\"),\n    \"UK National Insurance number\": re.compile(r\"\\b[A-CEGHJ-PR-TW-Z]{2}\\s?\\d{2}\\s?\\d{2}\\s?\\d{2}\\s?[A-D]\\b\", re.I),\n    \"card number\": re.compile(r\"\\b(?:\\d[ -]?){13,19}\\b\"),\n}\nSAFE_EMAIL_DOMAINS = (\"example.com\", \"example.org\", \"example.net\")\n\ndef luhn_ok(text):\n    digits = [int(c) for c in text if c.isdigit()][::-1]\n    total = sum(d if i % 2 == 0 else (d * 2 - 9 if d * 2 > 9 else d * 2) for i, d in enumerate(digits))\n    return 13 \u003C= len(digits) \u003C= 19 and total % 10 == 0\n\ndef findings_in_text(text):\n    for label, rx in PATTERNS.items():\n        for m in rx.finditer(text):\n            hit = m.group(0)\n            if label == \"email address\" and hit.lower().endswith(SAFE_EMAIL_DOMAINS):\n                continue\n            if label == \"card number\" and not luhn_ok(hit):\n                continue\n            yield label\n\ndef cell_texts(path):\n    if path.suffix.lower() == \".csv\":\n        with path.open(newline=\"\", encoding=\"utf-8\", errors=\"replace\") as f:\n            for r, row in enumerate(csv.reader(f), start=1):\n                for value in row:\n                    yield f\"row {r}\", value\n        return\n    from openpyxl import load_workbook\n    wb = load_workbook(path, read_only=True, data_only=True)\n    try:\n        for ws in wb.worksheets:\n            for row in ws.iter_rows():\n                for cell in row:\n                    if cell.value is not None:\n                        yield f\"{ws.title}!{cell.coordinate}\", str(cell.value)\n    finally:\n        wb.close()\n\ndef check(path, allowed):\n    problems = []\n    if path.name.startswith(\"~$\"):\n        return [f\"{path}: Office lock file — never commit these\"]\n    if path.stat().st_size > MAX_BYTES:\n        problems.append(f\"{path}: {path.stat().st_size \u002F 1024**2:.1f} MB is over the {MAX_BYTES \u002F\u002F 1024**2} MB limit\")\n    if str(path) in allowed:\n        return problems\n    seen = {}\n    for where, text in cell_texts(path):\n        for label in findings_in_text(text):\n            seen.setdefault(label, where)\n    problems += [f\"{path}: contains what looks like a real {label} (first at {where})\" for label, where in seen.items()]\n    return problems\n\ndef main(argv):\n    allowed = set(ALLOWLIST_FILE.read_text().split()) if ALLOWLIST_FILE.exists() else set()\n    problems = [p for name in argv for p in check(Path(name), allowed)]\n    for p in problems:\n        print(p)\n    if problems:\n        print(\"\\nCommit refused. Use synthetic data, or add a reviewed fixture to .spreadsheet-allowlist.\")\n    return 1 if problems else 0\n\nif __name__ == \"__main__\":\n    sys.exit(main(sys.argv[1:]))\n","python",[14,85,86,91,102,116,123,147,164,175,233,301,336,342,369,374,387,431,516,556,561,571,588,602,618,643,649,669,674,683,688,699,716,762,787,801,826,832,846,876,884,897,910,922,941,978,986,992,997,1008,1019,1032,1057,1073,1136,1152,1160,1171,1184,1196,1202,1256,1263,1268,1279,1314,1344,1356,1365,1372,1389,1406,1411,1426],{"__ignoreMap":35},[39,87,88],{"class":41,"line":42},[39,89,90],{"class":49},"\"\"\"Refuse to commit spreadsheets that are too large or look like they contain real personal data.\"\"\"\n",[39,92,94,98],{"class":41,"line":93},2,[39,95,97],{"class":96},"s-kum","import",[39,99,101],{"class":100},"skGVy"," csv, re, sys\n",[39,103,105,108,111,113],{"class":41,"line":104},3,[39,106,107],{"class":96},"from",[39,109,110],{"class":100}," pathlib ",[39,112,97],{"class":96},[39,114,115],{"class":100}," Path\n",[39,117,119],{"class":41,"line":118},4,[39,120,122],{"emptyLinePlaceholder":121},true,"\n",[39,124,126,130,133,136,139,142,144],{"class":41,"line":125},5,[39,127,129],{"class":128},"sP0c6","MAX_BYTES",[39,131,132],{"class":96}," =",[39,134,135],{"class":128}," 2",[39,137,138],{"class":96}," *",[39,140,141],{"class":128}," 1024",[39,143,138],{"class":96},[39,145,146],{"class":128}," 1024\n",[39,148,150,153,155,158,161],{"class":41,"line":149},6,[39,151,152],{"class":128},"ALLOWLIST_FILE",[39,154,132],{"class":96},[39,156,157],{"class":100}," Path(",[39,159,160],{"class":49},"\".spreadsheet-allowlist\"",[39,162,163],{"class":100},")\n",[39,165,167,170,172],{"class":41,"line":166},7,[39,168,169],{"class":128},"PATTERNS",[39,171,132],{"class":96},[39,173,174],{"class":100}," {\n",[39,176,178,181,184,187,190,193,197,200,203,206,208,211,213,215,219,221,224,226,228,230],{"class":41,"line":177},8,[39,179,180],{"class":49},"    \"email address\"",[39,182,183],{"class":100},": re.compile(",[39,185,186],{"class":96},"r",[39,188,189],{"class":49},"\"",[39,191,192],{"class":128},"[\\w",[39,194,196],{"class":195},"sSjpA",".+-",[39,198,199],{"class":128},"]",[39,201,202],{"class":96},"+",[39,204,205],{"class":49},"@",[39,207,192],{"class":128},[39,209,210],{"class":195},"-",[39,212,199],{"class":128},[39,214,202],{"class":96},[39,216,218],{"class":217},"s_b0D","\\.",[39,220,192],{"class":128},[39,222,223],{"class":195},".-",[39,225,199],{"class":128},[39,227,202],{"class":96},[39,229,189],{"class":49},[39,231,232],{"class":100},"),\n",[39,234,236,239,241,243,245,248,251,253,256,259,262,265,267,269,271,273,275,277,279,281,283,285,287,290,293,296,298],{"class":41,"line":235},9,[39,237,238],{"class":49},"    \"UK National Insurance number\"",[39,240,183],{"class":100},[39,242,186],{"class":96},[39,244,189],{"class":49},[39,246,247],{"class":128},"\\b[",[39,249,250],{"class":195},"A-CEGHJ-PR-TW-Z",[39,252,199],{"class":128},[39,254,255],{"class":96},"{2}",[39,257,258],{"class":128},"\\s",[39,260,261],{"class":96},"?",[39,263,264],{"class":128},"\\d",[39,266,255],{"class":96},[39,268,258],{"class":128},[39,270,261],{"class":96},[39,272,264],{"class":128},[39,274,255],{"class":96},[39,276,258],{"class":128},[39,278,261],{"class":96},[39,280,264],{"class":128},[39,282,255],{"class":96},[39,284,258],{"class":128},[39,286,261],{"class":96},[39,288,289],{"class":128},"[",[39,291,292],{"class":195},"A-D",[39,294,295],{"class":128},"]\\b",[39,297,189],{"class":49},[39,299,300],{"class":100},", re.I),\n",[39,302,304,307,309,311,313,316,319,321,323,326,329,332,334],{"class":41,"line":303},10,[39,305,306],{"class":49},"    \"card number\"",[39,308,183],{"class":100},[39,310,186],{"class":96},[39,312,189],{"class":49},[39,314,315],{"class":128},"\\b(?:\\d[",[39,317,318],{"class":195}," -",[39,320,199],{"class":128},[39,322,261],{"class":96},[39,324,325],{"class":128},")",[39,327,328],{"class":96},"{13,19}",[39,330,331],{"class":128},"\\b",[39,333,189],{"class":49},[39,335,232],{"class":100},[39,337,339],{"class":41,"line":338},11,[39,340,341],{"class":100},"}\n",[39,343,345,348,350,353,356,359,362,364,367],{"class":41,"line":344},12,[39,346,347],{"class":128},"SAFE_EMAIL_DOMAINS",[39,349,132],{"class":96},[39,351,352],{"class":100}," (",[39,354,355],{"class":49},"\"example.com\"",[39,357,358],{"class":100},", ",[39,360,361],{"class":49},"\"example.org\"",[39,363,358],{"class":100},[39,365,366],{"class":49},"\"example.net\"",[39,368,163],{"class":100},[39,370,372],{"class":41,"line":371},13,[39,373,122],{"emptyLinePlaceholder":121},[39,375,377,380,384],{"class":41,"line":376},14,[39,378,379],{"class":96},"def",[39,381,383],{"class":382},"s_Opv"," luhn_ok",[39,385,386],{"class":100},"(text):\n",[39,388,390,393,396,399,402,405,408,411,414,417,420,423,425,428],{"class":41,"line":389},15,[39,391,392],{"class":100},"    digits ",[39,394,395],{"class":96},"=",[39,397,398],{"class":100}," [",[39,400,401],{"class":128},"int",[39,403,404],{"class":100},"(c) ",[39,406,407],{"class":96},"for",[39,409,410],{"class":100}," c ",[39,412,413],{"class":96},"in",[39,415,416],{"class":100}," text ",[39,418,419],{"class":96},"if",[39,421,422],{"class":100}," c.isdigit()][::",[39,424,210],{"class":96},[39,426,427],{"class":128},"1",[39,429,430],{"class":100},"]\n",[39,432,434,437,439,442,445,447,450,453,455,458,461,464,467,470,472,474,477,480,483,485,487,490,492,494,496,498,500,503,505,508,510,513],{"class":41,"line":433},16,[39,435,436],{"class":100},"    total ",[39,438,395],{"class":96},[39,440,441],{"class":128}," sum",[39,443,444],{"class":100},"(d ",[39,446,419],{"class":96},[39,448,449],{"class":100}," i ",[39,451,452],{"class":96},"%",[39,454,135],{"class":128},[39,456,457],{"class":96}," ==",[39,459,460],{"class":128}," 0",[39,462,463],{"class":96}," else",[39,465,466],{"class":100}," (d ",[39,468,469],{"class":96},"*",[39,471,135],{"class":128},[39,473,318],{"class":96},[39,475,476],{"class":128}," 9",[39,478,479],{"class":96}," if",[39,481,482],{"class":100}," d ",[39,484,469],{"class":96},[39,486,135],{"class":128},[39,488,489],{"class":96}," >",[39,491,476],{"class":128},[39,493,463],{"class":96},[39,495,482],{"class":100},[39,497,469],{"class":96},[39,499,135],{"class":128},[39,501,502],{"class":100},") ",[39,504,407],{"class":96},[39,506,507],{"class":100}," i, d ",[39,509,413],{"class":96},[39,511,512],{"class":128}," enumerate",[39,514,515],{"class":100},"(digits))\n",[39,517,519,522,525,528,531,534,537,540,543,546,548,551,553],{"class":41,"line":518},17,[39,520,521],{"class":96},"    return",[39,523,524],{"class":128}," 13",[39,526,527],{"class":96}," \u003C=",[39,529,530],{"class":128}," len",[39,532,533],{"class":100},"(digits) ",[39,535,536],{"class":96},"\u003C=",[39,538,539],{"class":128}," 19",[39,541,542],{"class":96}," and",[39,544,545],{"class":100}," total ",[39,547,452],{"class":96},[39,549,550],{"class":128}," 10",[39,552,457],{"class":96},[39,554,555],{"class":128}," 0\n",[39,557,559],{"class":41,"line":558},18,[39,560,122],{"emptyLinePlaceholder":121},[39,562,564,566,569],{"class":41,"line":563},19,[39,565,379],{"class":96},[39,567,568],{"class":382}," findings_in_text",[39,570,386],{"class":100},[39,572,574,577,580,582,585],{"class":41,"line":573},20,[39,575,576],{"class":96},"    for",[39,578,579],{"class":100}," label, rx ",[39,581,413],{"class":96},[39,583,584],{"class":128}," PATTERNS",[39,586,587],{"class":100},".items():\n",[39,589,591,594,597,599],{"class":41,"line":590},21,[39,592,593],{"class":96},"        for",[39,595,596],{"class":100}," m ",[39,598,413],{"class":96},[39,600,601],{"class":100}," rx.finditer(text):\n",[39,603,605,608,610,613,616],{"class":41,"line":604},22,[39,606,607],{"class":100},"            hit ",[39,609,395],{"class":96},[39,611,612],{"class":100}," m.group(",[39,614,615],{"class":128},"0",[39,617,163],{"class":100},[39,619,621,624,627,630,633,635,638,640],{"class":41,"line":620},23,[39,622,623],{"class":96},"            if",[39,625,626],{"class":100}," label ",[39,628,629],{"class":96},"==",[39,631,632],{"class":49}," \"email address\"",[39,634,542],{"class":96},[39,636,637],{"class":100}," hit.lower().endswith(",[39,639,347],{"class":128},[39,641,642],{"class":100},"):\n",[39,644,646],{"class":41,"line":645},24,[39,647,648],{"class":96},"                continue\n",[39,650,652,654,656,658,661,663,666],{"class":41,"line":651},25,[39,653,623],{"class":96},[39,655,626],{"class":100},[39,657,629],{"class":96},[39,659,660],{"class":49}," \"card number\"",[39,662,542],{"class":96},[39,664,665],{"class":96}," not",[39,667,668],{"class":100}," luhn_ok(hit):\n",[39,670,672],{"class":41,"line":671},26,[39,673,648],{"class":96},[39,675,677,680],{"class":41,"line":676},27,[39,678,679],{"class":96},"            yield",[39,681,682],{"class":100}," label\n",[39,684,686],{"class":41,"line":685},28,[39,687,122],{"emptyLinePlaceholder":121},[39,689,691,693,696],{"class":41,"line":690},29,[39,692,379],{"class":96},[39,694,695],{"class":382}," cell_texts",[39,697,698],{"class":100},"(path):\n",[39,700,702,705,708,710,713],{"class":41,"line":701},30,[39,703,704],{"class":96},"    if",[39,706,707],{"class":100}," path.suffix.lower() ",[39,709,629],{"class":96},[39,711,712],{"class":49}," \".csv\"",[39,714,715],{"class":100},":\n",[39,717,719,722,725,729,731,734,736,739,741,744,746,749,751,754,756,759],{"class":41,"line":718},31,[39,720,721],{"class":96},"        with",[39,723,724],{"class":100}," path.open(",[39,726,728],{"class":727},"sa561","newline",[39,730,395],{"class":96},[39,732,733],{"class":49},"\"\"",[39,735,358],{"class":100},[39,737,738],{"class":727},"encoding",[39,740,395],{"class":96},[39,742,743],{"class":49},"\"utf-8\"",[39,745,358],{"class":100},[39,747,748],{"class":727},"errors",[39,750,395],{"class":96},[39,752,753],{"class":49},"\"replace\"",[39,755,502],{"class":100},[39,757,758],{"class":96},"as",[39,760,761],{"class":100}," f:\n",[39,763,765,768,771,773,775,778,781,783,785],{"class":41,"line":764},32,[39,766,767],{"class":96},"            for",[39,769,770],{"class":100}," r, row ",[39,772,413],{"class":96},[39,774,512],{"class":128},[39,776,777],{"class":100},"(csv.reader(f), ",[39,779,780],{"class":727},"start",[39,782,395],{"class":96},[39,784,427],{"class":128},[39,786,642],{"class":100},[39,788,790,793,796,798],{"class":41,"line":789},33,[39,791,792],{"class":96},"                for",[39,794,795],{"class":100}," value ",[39,797,413],{"class":96},[39,799,800],{"class":100}," row:\n",[39,802,804,807,810,813,816,818,821,823],{"class":41,"line":803},34,[39,805,806],{"class":96},"                    yield",[39,808,809],{"class":96}," f",[39,811,812],{"class":49},"\"row ",[39,814,815],{"class":195},"{",[39,817,186],{"class":100},[39,819,820],{"class":195},"}",[39,822,189],{"class":49},[39,824,825],{"class":100},", value\n",[39,827,829],{"class":41,"line":828},35,[39,830,831],{"class":96},"        return\n",[39,833,835,838,841,843],{"class":41,"line":834},36,[39,836,837],{"class":96},"    from",[39,839,840],{"class":100}," openpyxl ",[39,842,97],{"class":96},[39,844,845],{"class":100}," load_workbook\n",[39,847,849,852,854,857,860,862,865,867,870,872,874],{"class":41,"line":848},37,[39,850,851],{"class":100},"    wb ",[39,853,395],{"class":96},[39,855,856],{"class":100}," load_workbook(path, ",[39,858,859],{"class":727},"read_only",[39,861,395],{"class":96},[39,863,864],{"class":128},"True",[39,866,358],{"class":100},[39,868,869],{"class":727},"data_only",[39,871,395],{"class":96},[39,873,864],{"class":128},[39,875,163],{"class":100},[39,877,879,882],{"class":41,"line":878},38,[39,880,881],{"class":96},"    try",[39,883,715],{"class":100},[39,885,887,889,892,894],{"class":41,"line":886},39,[39,888,593],{"class":96},[39,890,891],{"class":100}," ws ",[39,893,413],{"class":96},[39,895,896],{"class":100}," wb.worksheets:\n",[39,898,900,902,905,907],{"class":41,"line":899},40,[39,901,767],{"class":96},[39,903,904],{"class":100}," row ",[39,906,413],{"class":96},[39,908,909],{"class":100}," ws.iter_rows():\n",[39,911,913,915,918,920],{"class":41,"line":912},41,[39,914,792],{"class":96},[39,916,917],{"class":100}," cell ",[39,919,413],{"class":96},[39,921,800],{"class":100},[39,923,925,928,931,934,936,939],{"class":41,"line":924},42,[39,926,927],{"class":96},"                    if",[39,929,930],{"class":100}," cell.value ",[39,932,933],{"class":96},"is",[39,935,665],{"class":96},[39,937,938],{"class":128}," None",[39,940,715],{"class":100},[39,942,944,947,949,951,953,956,958,961,963,966,968,970,972,975],{"class":41,"line":943},43,[39,945,946],{"class":96},"                        yield",[39,948,809],{"class":96},[39,950,189],{"class":49},[39,952,815],{"class":195},[39,954,955],{"class":100},"ws.title",[39,957,820],{"class":195},[39,959,960],{"class":49},"!",[39,962,815],{"class":195},[39,964,965],{"class":100},"cell.coordinate",[39,967,820],{"class":195},[39,969,189],{"class":49},[39,971,358],{"class":100},[39,973,974],{"class":128},"str",[39,976,977],{"class":100},"(cell.value)\n",[39,979,981,984],{"class":41,"line":980},44,[39,982,983],{"class":96},"    finally",[39,985,715],{"class":100},[39,987,989],{"class":41,"line":988},45,[39,990,991],{"class":100},"        wb.close()\n",[39,993,995],{"class":41,"line":994},46,[39,996,122],{"emptyLinePlaceholder":121},[39,998,1000,1002,1005],{"class":41,"line":999},47,[39,1001,379],{"class":96},[39,1003,1004],{"class":382}," check",[39,1006,1007],{"class":100},"(path, allowed):\n",[39,1009,1011,1014,1016],{"class":41,"line":1010},48,[39,1012,1013],{"class":100},"    problems ",[39,1015,395],{"class":96},[39,1017,1018],{"class":100}," []\n",[39,1020,1022,1024,1027,1030],{"class":41,"line":1021},49,[39,1023,704],{"class":96},[39,1025,1026],{"class":100}," path.name.startswith(",[39,1028,1029],{"class":49},"\"~$\"",[39,1031,642],{"class":100},[39,1033,1035,1038,1040,1043,1045,1047,1050,1052,1055],{"class":41,"line":1034},50,[39,1036,1037],{"class":96},"        return",[39,1039,398],{"class":100},[39,1041,1042],{"class":96},"f",[39,1044,189],{"class":49},[39,1046,815],{"class":195},[39,1048,1049],{"class":100},"path",[39,1051,820],{"class":195},[39,1053,1054],{"class":49},": Office lock file — never commit these\"",[39,1056,430],{"class":100},[39,1058,1060,1062,1065,1068,1071],{"class":41,"line":1059},51,[39,1061,704],{"class":96},[39,1063,1064],{"class":100}," path.stat().st_size ",[39,1066,1067],{"class":96},">",[39,1069,1070],{"class":128}," MAX_BYTES",[39,1072,715],{"class":100},[39,1074,1076,1079,1081,1083,1085,1087,1089,1092,1094,1097,1100,1102,1105,1108,1111,1113,1116,1118,1120,1123,1125,1127,1129,1131,1134],{"class":41,"line":1075},52,[39,1077,1078],{"class":100},"        problems.append(",[39,1080,1042],{"class":96},[39,1082,189],{"class":49},[39,1084,815],{"class":195},[39,1086,1049],{"class":100},[39,1088,820],{"class":195},[39,1090,1091],{"class":49},": ",[39,1093,815],{"class":195},[39,1095,1096],{"class":100},"path.stat().st_size ",[39,1098,1099],{"class":96},"\u002F",[39,1101,141],{"class":128},[39,1103,1104],{"class":96},"**",[39,1106,1107],{"class":128},"2",[39,1109,1110],{"class":96},":.1f",[39,1112,820],{"class":195},[39,1114,1115],{"class":49}," MB is over the ",[39,1117,815],{"class":195},[39,1119,129],{"class":128},[39,1121,1122],{"class":96}," \u002F\u002F",[39,1124,141],{"class":128},[39,1126,1104],{"class":96},[39,1128,1107],{"class":128},[39,1130,820],{"class":195},[39,1132,1133],{"class":49}," MB limit\"",[39,1135,163],{"class":100},[39,1137,1139,1141,1144,1147,1149],{"class":41,"line":1138},53,[39,1140,704],{"class":96},[39,1142,1143],{"class":128}," str",[39,1145,1146],{"class":100},"(path) ",[39,1148,413],{"class":96},[39,1150,1151],{"class":100}," allowed:\n",[39,1153,1155,1157],{"class":41,"line":1154},54,[39,1156,1037],{"class":96},[39,1158,1159],{"class":100}," problems\n",[39,1161,1163,1166,1168],{"class":41,"line":1162},55,[39,1164,1165],{"class":100},"    seen ",[39,1167,395],{"class":96},[39,1169,1170],{"class":100}," {}\n",[39,1172,1174,1176,1179,1181],{"class":41,"line":1173},56,[39,1175,576],{"class":96},[39,1177,1178],{"class":100}," where, text ",[39,1180,413],{"class":96},[39,1182,1183],{"class":100}," cell_texts(path):\n",[39,1185,1187,1189,1191,1193],{"class":41,"line":1186},57,[39,1188,593],{"class":96},[39,1190,626],{"class":100},[39,1192,413],{"class":96},[39,1194,1195],{"class":100}," findings_in_text(text):\n",[39,1197,1199],{"class":41,"line":1198},58,[39,1200,1201],{"class":100},"            seen.setdefault(label, where)\n",[39,1203,1205,1207,1210,1212,1214,1216,1218,1220,1222,1225,1227,1230,1232,1235,1237,1240,1242,1245,1248,1251,1253],{"class":41,"line":1204},59,[39,1206,1013],{"class":100},[39,1208,1209],{"class":96},"+=",[39,1211,398],{"class":100},[39,1213,1042],{"class":96},[39,1215,189],{"class":49},[39,1217,815],{"class":195},[39,1219,1049],{"class":100},[39,1221,820],{"class":195},[39,1223,1224],{"class":49},": contains what looks like a real ",[39,1226,815],{"class":195},[39,1228,1229],{"class":100},"label",[39,1231,820],{"class":195},[39,1233,1234],{"class":49}," (first at ",[39,1236,815],{"class":195},[39,1238,1239],{"class":100},"where",[39,1241,820],{"class":195},[39,1243,1244],{"class":49},")\"",[39,1246,1247],{"class":96}," for",[39,1249,1250],{"class":100}," label, where ",[39,1252,413],{"class":96},[39,1254,1255],{"class":100}," seen.items()]\n",[39,1257,1259,1261],{"class":41,"line":1258},60,[39,1260,521],{"class":96},[39,1262,1159],{"class":100},[39,1264,1266],{"class":41,"line":1265},61,[39,1267,122],{"emptyLinePlaceholder":121},[39,1269,1271,1273,1276],{"class":41,"line":1270},62,[39,1272,379],{"class":96},[39,1274,1275],{"class":382}," main",[39,1277,1278],{"class":100},"(argv):\n",[39,1280,1282,1285,1287,1290,1293,1295,1298,1300,1303,1306,1309,1311],{"class":41,"line":1281},63,[39,1283,1284],{"class":100},"    allowed ",[39,1286,395],{"class":96},[39,1288,1289],{"class":128}," set",[39,1291,1292],{"class":100},"(",[39,1294,152],{"class":128},[39,1296,1297],{"class":100},".read_text().split()) ",[39,1299,419],{"class":96},[39,1301,1302],{"class":128}," ALLOWLIST_FILE",[39,1304,1305],{"class":100},".exists() ",[39,1307,1308],{"class":96},"else",[39,1310,1289],{"class":128},[39,1312,1313],{"class":100},"()\n",[39,1315,1317,1319,1321,1324,1326,1329,1331,1334,1336,1339,1341],{"class":41,"line":1316},64,[39,1318,1013],{"class":100},[39,1320,395],{"class":96},[39,1322,1323],{"class":100}," [p ",[39,1325,407],{"class":96},[39,1327,1328],{"class":100}," name ",[39,1330,413],{"class":96},[39,1332,1333],{"class":100}," argv ",[39,1335,407],{"class":96},[39,1337,1338],{"class":100}," p ",[39,1340,413],{"class":96},[39,1342,1343],{"class":100}," check(Path(name), allowed)]\n",[39,1345,1347,1349,1351,1353],{"class":41,"line":1346},65,[39,1348,576],{"class":96},[39,1350,1338],{"class":100},[39,1352,413],{"class":96},[39,1354,1355],{"class":100}," problems:\n",[39,1357,1359,1362],{"class":41,"line":1358},66,[39,1360,1361],{"class":128},"        print",[39,1363,1364],{"class":100},"(p)\n",[39,1366,1368,1370],{"class":41,"line":1367},67,[39,1369,704],{"class":96},[39,1371,1355],{"class":100},[39,1373,1375,1377,1379,1381,1384,1387],{"class":41,"line":1374},68,[39,1376,1361],{"class":128},[39,1378,1292],{"class":100},[39,1380,189],{"class":49},[39,1382,1383],{"class":195},"\\n",[39,1385,1386],{"class":49},"Commit refused. Use synthetic data, or add a reviewed fixture to .spreadsheet-allowlist.\"",[39,1388,163],{"class":100},[39,1390,1392,1394,1397,1399,1402,1404],{"class":41,"line":1391},69,[39,1393,521],{"class":96},[39,1395,1396],{"class":128}," 1",[39,1398,479],{"class":96},[39,1400,1401],{"class":100}," problems ",[39,1403,1308],{"class":96},[39,1405,555],{"class":128},[39,1407,1409],{"class":41,"line":1408},70,[39,1410,122],{"emptyLinePlaceholder":121},[39,1412,1414,1416,1419,1421,1424],{"class":41,"line":1413},71,[39,1415,419],{"class":96},[39,1417,1418],{"class":128}," __name__",[39,1420,457],{"class":96},[39,1422,1423],{"class":49}," \"__main__\"",[39,1425,715],{"class":100},[39,1427,1429,1432,1434],{"class":41,"line":1428},72,[39,1430,1431],{"class":100},"    sys.exit(main(sys.argv[",[39,1433,427],{"class":128},[39,1435,1436],{"class":100},":]))\n",[10,1438,1439,1440,1443],{},"The script receives the staged file names as arguments, checks each one and exits with status 1 if anything is wrong, which makes git abort the commit. Three checks run. Lock files — the ",[14,1441,1442],{},"~$report.xlsx"," files Excel leaves while a workbook is open — are always refused. Files over the size limit are refused, because large spreadsheets bloat every clone forever and usually turn out to be real extracts. And the content of every cell is scanned for things that look like personal data: email addresses, UK National Insurance numbers and payment card numbers.",[10,1445,1446,1447,1450],{},"Pattern matching on its own produces false alarms, so each pattern is narrowed. Email addresses at ",[14,1448,1449],{},"example.com"," and its siblings are allowed, because those domains are reserved for documentation and test data. A run of thirteen to nineteen digits only counts as a card number if it passes the Luhn checksum that real card numbers satisfy, which rules out most invoice numbers and phone numbers. The message names the first cell where each kind of data appears, so the person committing can find it quickly. The hook reads workbooks in read-only mode, which keeps it fast on larger files.",[1452,1453,1461,1462,1461,1466,1461,1470,1461,1477,1461,1487,1461,1494,1461,1499,1461,1505,1461,1510,1461,1514,1461,1519,1461,1523,1461,1527,1461,1531,1461,1534,1461,1539,1461,1544,1461,1548,1461,1551,1461,1555,1461,1558,1461,1563,1461,1568,1461,1571,1461,1574,1461,1578,1461,1581,1461,1586,1461,1592,1461,1595,1461,1598],"svg",{"viewBox":1454,"role":1455,"ariaLabelledBy":1456,"xmlns":1459,"style":1460},"4 0 752 126","img",[1457,1458],"pc-flow-t","pc-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[1463,1464,1465],"title",{"id":1457},"What happens on git commit",[1467,1468,1469],"desc",{"id":1458},"git commit triggers pre-commit, which passes the staged spreadsheets to the checker. Lock files and oversized files are refused outright. Other files have their cells scanned for emails, card numbers and National Insurance numbers unless they are on the allowlist. Any problem refuses the commit.",[1471,1472],"rect",{"x":1473,"y":615,"width":1474,"height":1475,"fill":1476},"4","752","126","#ffffff",[1471,1478],{"x":1479,"y":1480,"width":1481,"height":1482,"rx":1483,"fill":1484,"stroke":1485,"style":1486},"20.0","16.0","115.2","94.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[1488,1489,1493],"text",{"x":1490,"y":1491,"style":1492},"77.6","59.0","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","git commit",[1488,1495,1498],{"x":1490,"y":1496,"style":1497},"78.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","files staged",[41,1500],{"x1":1501,"y1":1502,"x2":1503,"y2":1502,"stroke":1504,"style":1486},"140.2","63.0","162.2","var(--brand,#5b5cf0)",[1506,1507],"polygon",{"points":1508,"fill":1509},"166.2,63.0 157.2,68.0 157.2,58.0","#5b5cf0",[1471,1511],{"x":1512,"y":1480,"width":1481,"height":1482,"rx":1483,"fill":1513,"stroke":1504,"style":1486},"171.2","#f0f4ff",[1488,1515,65],{"x":1516,"y":1517,"style":1518},"228.8","50.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[1488,1520,1522],{"x":1516,"y":1521,"style":1497},"69.8","passes *.xlsx,",[1488,1524,1526],{"x":1516,"y":1525,"style":1497},"87.2","*.csv",[41,1528],{"x1":1529,"y1":1502,"x2":1530,"y2":1502,"stroke":1504,"style":1486},"291.4","313.4",[1506,1532],{"points":1533,"fill":1509},"317.4,63.0 308.4,68.0 308.4,58.0",[1471,1535],{"x":1536,"y":1480,"width":1481,"height":1482,"rx":1483,"fill":1537,"stroke":1538,"style":1486},"322.4","#fce9e9","var(--danger,#dc2626)",[1488,1540,1543],{"x":1541,"y":1517,"style":1542},"380.0","font-size:13.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Lock file or",[1488,1545,1547],{"x":1541,"y":1546,"style":1542},"67.8","too big?",[1488,1549,1550],{"x":1541,"y":1525,"style":1497},"refuse",[41,1552],{"x1":1553,"y1":1502,"x2":1554,"y2":1502,"stroke":1504,"style":1486},"442.6","464.6",[1506,1556],{"points":1557,"fill":1509},"468.6,63.0 459.6,68.0 459.6,58.0",[1471,1559],{"x":1560,"y":1480,"width":1481,"height":1482,"rx":1483,"fill":1561,"stroke":1562,"style":1486},"473.6","#fdefd8","var(--gold,#b4740a)",[1488,1564,1567],{"x":1565,"y":1517,"style":1566},"531.2","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Scan cells",[1488,1569,1570],{"x":1565,"y":1521,"style":1497},"emails, cards,",[1488,1572,1573],{"x":1565,"y":1525,"style":1497},"NI numbers",[41,1575],{"x1":1576,"y1":1502,"x2":1577,"y2":1502,"stroke":1504,"style":1486},"593.8","615.8",[1506,1579],{"points":1580,"fill":1509},"619.8,63.0 610.8,68.0 610.8,58.0",[1471,1582],{"x":1583,"y":1480,"width":1481,"height":1482,"rx":1483,"fill":1584,"stroke":1585,"style":1486},"624.8","#d9f4f1","var(--teal,#0f9488)",[1488,1587,1591],{"x":1588,"y":1589,"style":1590},"682.4","41.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Clean or",[1488,1593,1594],{"x":1588,"y":1491,"style":1590},"allowlisted",[1488,1596,1597],{"x":1588,"y":1496,"style":1497},"commit",[1488,1599,1601],{"x":1588,"y":1600,"style":1497},"96.0","proceeds",[25,1603,1605],{"id":1604},"step-2-register-it-with-pre-commit","Step 2 — Register it with pre-commit",[30,1607,1611],{"className":1608,"code":1609,"language":1610,"meta":35,"style":35},"language-yaml shiki shiki-themes github-light github-dark-high-contrast","repos:\n  - repo: local\n    hooks:\n      - id: check-spreadsheets\n        name: block large or sensitive spreadsheets\n        entry: python tools\u002Fcheck_spreadsheets.py\n        language: system\n        files: '\\.(xlsx|xlsm|csv)$'\n","yaml",[14,1612,1613,1621,1634,1641,1654,1664,1674,1684],{"__ignoreMap":35},[39,1614,1615,1619],{"class":41,"line":42},[39,1616,1618],{"class":1617},"sx1sP","repos",[39,1620,715],{"class":100},[39,1622,1623,1626,1629,1631],{"class":41,"line":93},[39,1624,1625],{"class":100},"  - ",[39,1627,1628],{"class":1617},"repo",[39,1630,1091],{"class":100},[39,1632,1633],{"class":49},"local\n",[39,1635,1636,1639],{"class":41,"line":104},[39,1637,1638],{"class":1617},"    hooks",[39,1640,715],{"class":100},[39,1642,1643,1646,1649,1651],{"class":41,"line":118},[39,1644,1645],{"class":100},"      - ",[39,1647,1648],{"class":1617},"id",[39,1650,1091],{"class":100},[39,1652,1653],{"class":49},"check-spreadsheets\n",[39,1655,1656,1659,1661],{"class":41,"line":125},[39,1657,1658],{"class":1617},"        name",[39,1660,1091],{"class":100},[39,1662,1663],{"class":49},"block large or sensitive spreadsheets\n",[39,1665,1666,1669,1671],{"class":41,"line":149},[39,1667,1668],{"class":1617},"        entry",[39,1670,1091],{"class":100},[39,1672,1673],{"class":49},"python tools\u002Fcheck_spreadsheets.py\n",[39,1675,1676,1679,1681],{"class":41,"line":166},[39,1677,1678],{"class":1617},"        language",[39,1680,1091],{"class":100},[39,1682,1683],{"class":49},"system\n",[39,1685,1686,1689,1691],{"class":41,"line":177},[39,1687,1688],{"class":1617},"        files",[39,1690,1091],{"class":100},[39,1692,1693],{"class":49},"'\\.(xlsx|xlsm|csv)$'\n",[10,1695,1696,1697,1700,1701,1704,1705,1708,1709,1712,1713,1716],{},"A ",[14,1698,1699],{},"local"," repository entry runs a script that lives in the project itself, so the hook needs no download and is reviewed like any other code. ",[14,1702,1703],{},"language: system"," runs it with the Python on the developer's PATH, which in an Excel automation project is the project's virtual environment with openpyxl already installed. ",[14,1706,1707],{},"files"," limits the hook to spreadsheets, so commits that only touch Python code do not pay for it. Each developer runs ",[14,1710,1711],{},"pre-commit install"," once after cloning, which writes the git hook; the same command run in CI with ",[14,1714,1715],{},"pre-commit run --all-files"," catches anyone who has not installed it.",[25,1718,1720],{"id":1719},"step-3-test-it-in-a-scratch-repository","Step 3 — Test it in a scratch repository",[30,1722,1724],{"className":32,"code":1723,"language":34,"meta":35,"style":35},"set -e\nrm -rf demo && mkdir demo && cd demo && git init -q && git config user.email dev@example.com && git config user.name Dev\nmkdir -p tools tests\u002Ffixtures && cp ..\u002Ftools\u002Fcheck_spreadsheets.py tools\u002F && cp ..\u002F.pre-commit-config.yaml .\npython - \u003C\u003C'EOF'\nfrom openpyxl import Workbook\nwb = Workbook(); ws = wb.active\nws.append([\"Customer\", \"Email\", \"Card\"]); ws.append([\"Synthetic Ltd\", \"buyer@example.com\", \"4111 1111 1111 1112\"])\nwb.save(\"tests\u002Ffixtures\u002Fclean.xlsx\")\nwb = Workbook(); ws = wb.active\nws.append([\"Customer\", \"Email\", \"NI number\", \"Card\"]); ws.append([\"J Smith\", \"j.smith@gmail.com\", \"JK 12 34 56 C\", \"4111 1111 1111 1111\"])\nwb.save(\"tests\u002Ffixtures\u002Freal_extract.xlsx\")\nEOF\ngit add .pre-commit-config.yaml tools && git commit -q -m \"add hook\" && pre-commit install > \u002Fdev\u002Fnull\ngit add tests\u002Ffixtures\u002Fclean.xlsx && git commit -q -m \"clean fixture\" && echo \"clean fixture: committed\"\ngit add tests\u002Ffixtures\u002Freal_extract.xlsx\ngit commit -q -m \"real data\" || echo \"real extract: commit refused\"\n",[14,1725,1726,1734,1796,1830,1843,1848,1853,1858,1863,1867,1872,1877,1882,1920,1950,1959],{"__ignoreMap":35},[39,1727,1728,1731],{"class":41,"line":42},[39,1729,1730],{"class":128},"set",[39,1732,1733],{"class":128}," -e\n",[39,1735,1736,1739,1742,1745,1748,1751,1753,1755,1758,1760,1762,1765,1768,1771,1773,1775,1778,1781,1784,1786,1788,1790,1793],{"class":41,"line":93},[39,1737,1738],{"class":45},"rm",[39,1740,1741],{"class":128}," -rf",[39,1743,1744],{"class":49}," demo",[39,1746,1747],{"class":100}," && ",[39,1749,1750],{"class":45},"mkdir",[39,1752,1744],{"class":49},[39,1754,1747],{"class":100},[39,1756,1757],{"class":128},"cd",[39,1759,1744],{"class":49},[39,1761,1747],{"class":100},[39,1763,1764],{"class":45},"git",[39,1766,1767],{"class":49}," init",[39,1769,1770],{"class":128}," -q",[39,1772,1747],{"class":100},[39,1774,1764],{"class":45},[39,1776,1777],{"class":49}," config",[39,1779,1780],{"class":49}," user.email",[39,1782,1783],{"class":49}," dev@example.com",[39,1785,1747],{"class":100},[39,1787,1764],{"class":45},[39,1789,1777],{"class":49},[39,1791,1792],{"class":49}," user.name",[39,1794,1795],{"class":49}," Dev\n",[39,1797,1798,1800,1803,1806,1809,1811,1814,1817,1820,1822,1824,1827],{"class":41,"line":104},[39,1799,1750],{"class":45},[39,1801,1802],{"class":128}," -p",[39,1804,1805],{"class":49}," tools",[39,1807,1808],{"class":49}," tests\u002Ffixtures",[39,1810,1747],{"class":100},[39,1812,1813],{"class":45},"cp",[39,1815,1816],{"class":49}," ..\u002Ftools\u002Fcheck_spreadsheets.py",[39,1818,1819],{"class":49}," tools\u002F",[39,1821,1747],{"class":100},[39,1823,1813],{"class":45},[39,1825,1826],{"class":49}," ..\u002F.pre-commit-config.yaml",[39,1828,1829],{"class":49}," .\n",[39,1831,1832,1834,1836,1839],{"class":41,"line":118},[39,1833,83],{"class":45},[39,1835,318],{"class":49},[39,1837,1838],{"class":96}," \u003C\u003C",[39,1840,1842],{"class":1841},"sXKb-","'EOF'\n",[39,1844,1845],{"class":41,"line":125},[39,1846,1847],{"class":49},"from openpyxl import Workbook\n",[39,1849,1850],{"class":41,"line":149},[39,1851,1852],{"class":49},"wb = Workbook(); ws = wb.active\n",[39,1854,1855],{"class":41,"line":166},[39,1856,1857],{"class":49},"ws.append([\"Customer\", \"Email\", \"Card\"]); ws.append([\"Synthetic Ltd\", \"buyer@example.com\", \"4111 1111 1111 1112\"])\n",[39,1859,1860],{"class":41,"line":177},[39,1861,1862],{"class":49},"wb.save(\"tests\u002Ffixtures\u002Fclean.xlsx\")\n",[39,1864,1865],{"class":41,"line":235},[39,1866,1852],{"class":49},[39,1868,1869],{"class":41,"line":303},[39,1870,1871],{"class":49},"ws.append([\"Customer\", \"Email\", \"NI number\", \"Card\"]); ws.append([\"J Smith\", \"j.smith@gmail.com\", \"JK 12 34 56 C\", \"4111 1111 1111 1111\"])\n",[39,1873,1874],{"class":41,"line":338},[39,1875,1876],{"class":49},"wb.save(\"tests\u002Ffixtures\u002Freal_extract.xlsx\")\n",[39,1878,1879],{"class":41,"line":344},[39,1880,1881],{"class":1841},"EOF\n",[39,1883,1884,1886,1889,1892,1894,1896,1898,1901,1903,1906,1909,1911,1913,1915,1917],{"class":41,"line":371},[39,1885,1764],{"class":45},[39,1887,1888],{"class":49}," add",[39,1890,1891],{"class":49}," .pre-commit-config.yaml",[39,1893,1805],{"class":49},[39,1895,1747],{"class":100},[39,1897,1764],{"class":45},[39,1899,1900],{"class":49}," commit",[39,1902,1770],{"class":128},[39,1904,1905],{"class":128}," -m",[39,1907,1908],{"class":49}," \"add hook\"",[39,1910,1747],{"class":100},[39,1912,65],{"class":45},[39,1914,50],{"class":49},[39,1916,489],{"class":96},[39,1918,1919],{"class":49}," \u002Fdev\u002Fnull\n",[39,1921,1922,1924,1926,1929,1931,1933,1935,1937,1939,1942,1944,1947],{"class":41,"line":376},[39,1923,1764],{"class":45},[39,1925,1888],{"class":49},[39,1927,1928],{"class":49}," tests\u002Ffixtures\u002Fclean.xlsx",[39,1930,1747],{"class":100},[39,1932,1764],{"class":45},[39,1934,1900],{"class":49},[39,1936,1770],{"class":128},[39,1938,1905],{"class":128},[39,1940,1941],{"class":49}," \"clean fixture\"",[39,1943,1747],{"class":100},[39,1945,1946],{"class":128},"echo",[39,1948,1949],{"class":49}," \"clean fixture: committed\"\n",[39,1951,1952,1954,1956],{"class":41,"line":389},[39,1953,1764],{"class":45},[39,1955,1888],{"class":49},[39,1957,1958],{"class":49}," tests\u002Ffixtures\u002Freal_extract.xlsx\n",[39,1960,1961,1963,1965,1967,1969,1972,1975,1978],{"class":41,"line":433},[39,1962,1764],{"class":45},[39,1964,1900],{"class":49},[39,1966,1770],{"class":128},[39,1968,1905],{"class":128},[39,1970,1971],{"class":49}," \"real data\"",[39,1973,1974],{"class":96}," ||",[39,1976,1977],{"class":128}," echo",[39,1979,1980],{"class":49}," \"real extract: commit refused\"\n",[10,1982,1983,1984,1986,1987,1990,1991,1994],{},"The scratch repository goes through the real sequence: the hook is installed, a fixture with synthetic data is committed without complaint, and a workbook copied from a \"real\" extract is refused with the reasons listed. The clean fixture passes even though it has an email address and a sixteen-digit number — the address is at ",[14,1985,1449],{},", and ",[14,1988,1989],{},"4111 1111 1111 1112"," fails the Luhn check, so it cannot be a real card number. The extract fails on all three counts: a Gmail address, a National Insurance number and ",[14,1992,1993],{},"4111 1111 1111 1111",", which passes the Luhn check (it is the well-known Visa test number, which is exactly why a hook cannot tell it from a real one).",[1452,1996,1461,2002,1461,2005,1461,2008,1461,2013,1461,2019,1461,2023,1461,2029,1461,2033,1461,2037,1461,2041,1461,2045,1461,2049,1461,2052,1461,2055,1461,2059,1461,2061,1461,2064,1461,2066,1461,2069,1461,2072,1461,2075,1461,2077,1461,2081,1461,2083,1461,2087,1461,2089,1461,2093,1461,2095,1461,2099,1461,2101,1461,2104,1461,2106,1461,2109,1461,2111,1461,2114,1461,2117,1461,2120,1461,2122,1461,2125,1461,2127,1461,2130,1461,2132],{"viewBox":1997,"role":1455,"ariaLabelledBy":1998,"xmlns":1459,"style":2001},"70 0 619 168",[1999,2000],"pc-results-t","pc-results-d","width:100%;max-width:619px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[1463,2003,2004],{"id":1999},"What the hook decided",[1467,2006,2007],{"id":2000},"The synthetic fixture passes because its email is at example.com and its sixteen-digit number fails the Luhn check. The real extract is refused for a Gmail address, a National Insurance number and a number that passes the Luhn check.",[1471,2009],{"x":2010,"y":615,"width":2011,"height":2012,"fill":1476},"70","619","168",[1471,2014],{"x":2015,"y":1480,"width":2016,"height":2017,"rx":615,"fill":1484,"stroke":1485,"style":2018},"86.9","32.0","24.0","stroke-width:1px",[1471,2020],{"x":2021,"y":1480,"width":2022,"height":2017,"rx":615,"fill":1484,"stroke":1485,"style":2018},"118.9","121.6",[1488,2024,2028],{"x":2025,"y":2026,"style":2027},"179.7","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[1471,2030],{"x":2031,"y":1480,"width":2032,"height":2017,"rx":615,"fill":1484,"stroke":1485,"style":2018},"240.5","222.8",[1488,2034,2036],{"x":2035,"y":2026,"style":2027},"351.9","B",[1471,2038],{"x":2039,"y":1480,"width":2040,"height":2017,"rx":615,"fill":1484,"stroke":1485,"style":2018},"463.2","209.9",[1488,2042,2044],{"x":2043,"y":2026,"style":2027},"568.2","C",[1471,2046],{"x":2015,"y":2047,"width":2016,"height":2048,"rx":615,"fill":1484,"stroke":1485,"style":2018},"40.0","28.0",[1488,2050,427],{"x":2051,"y":1491,"style":2027},"102.9",[1471,2053],{"x":2021,"y":2047,"width":2022,"height":2048,"rx":615,"fill":1513,"stroke":1504,"style":2054},"stroke-width:1.5px",[1488,2056,2058],{"x":2025,"y":1491,"style":2057},"font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Cell value",[1471,2060],{"x":2031,"y":2047,"width":2032,"height":2048,"rx":615,"fill":1513,"stroke":1504,"style":2054},[1488,2062,2063],{"x":2035,"y":1491,"style":2057},"In clean.xlsx",[1471,2065],{"x":2039,"y":2047,"width":2040,"height":2048,"rx":615,"fill":1513,"stroke":1504,"style":2054},[1488,2067,2068],{"x":2043,"y":1491,"style":2057},"In real_extract.xlsx",[1471,2070],{"x":2015,"y":2071,"width":2016,"height":2048,"rx":615,"fill":1484,"stroke":1485,"style":2018},"68.0",[1488,2073,1107],{"x":2051,"y":2074,"style":2027},"87.0",[1471,2076],{"x":2021,"y":2071,"width":2022,"height":2048,"rx":615,"fill":1476,"stroke":1485,"style":2018},[1488,2078,2080],{"x":2025,"y":2074,"style":2079},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Email",[1471,2082],{"x":2031,"y":2071,"width":2032,"height":2048,"rx":615,"fill":1584,"stroke":1585,"style":2054},[1488,2084,2086],{"x":2035,"y":2074,"style":2085},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","buyer@example.com — allowed",[1471,2088],{"x":2039,"y":2071,"width":2040,"height":2048,"rx":615,"fill":1537,"stroke":1538,"style":2054},[1488,2090,2092],{"x":2043,"y":2074,"style":2091},"font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","j.smith@gmail.com — refused",[1471,2094],{"x":2015,"y":1600,"width":2016,"height":2048,"rx":615,"fill":1484,"stroke":1485,"style":2018},[1488,2096,2098],{"x":2051,"y":2097,"style":2027},"115.0","3",[1471,2100],{"x":2021,"y":1600,"width":2022,"height":2048,"rx":615,"fill":1476,"stroke":1485,"style":2018},[1488,2102,2103],{"x":2025,"y":2097,"style":2079},"NI number",[1471,2105],{"x":2031,"y":1600,"width":2032,"height":2048,"rx":615,"fill":1476,"stroke":1485,"style":2018},[1488,2107,2108],{"x":2035,"y":2097,"style":2079},"—",[1471,2110],{"x":2039,"y":1600,"width":2040,"height":2048,"rx":615,"fill":1537,"stroke":1538,"style":2054},[1488,2112,2113],{"x":2043,"y":2097,"style":2091},"JK 12 34 56 C — refused",[1471,2115],{"x":2015,"y":2116,"width":2016,"height":2048,"rx":615,"fill":1484,"stroke":1485,"style":2018},"124.0",[1488,2118,1473],{"x":2051,"y":2119,"style":2027},"143.0",[1471,2121],{"x":2021,"y":2116,"width":2022,"height":2048,"rx":615,"fill":1476,"stroke":1485,"style":2018},[1488,2123,2124],{"x":2025,"y":2119,"style":2079},"16-digit number",[1471,2126],{"x":2031,"y":2116,"width":2032,"height":2048,"rx":615,"fill":1584,"stroke":1585,"style":2054},[1488,2128,2129],{"x":2035,"y":2119,"style":2085},"…1112 — fails Luhn, allowed",[1471,2131],{"x":2039,"y":2116,"width":2040,"height":2048,"rx":615,"fill":1537,"stroke":1538,"style":2054},[1488,2133,2134],{"x":2043,"y":2119,"style":2091},"…1111 — passes Luhn, refused",[25,2136,2138],{"id":2137},"the-allowlist-and-its-limits","The allowlist and its limits",[10,2140,2141,2142,2145,2146,2149],{},"Some fixtures legitimately need data that looks real — a test of the card-masking code needs a valid card number in its input. Those files go in ",[14,2143,2144],{},".spreadsheet-allowlist",", one path per line, which is committed and reviewed like code, so adding a file to it is a visible decision rather than a quiet bypass. The size limit still applies to allowlisted files. Anyone can skip all hooks with ",[14,2147,2148],{},"git commit --no-verify",", so treat the hook as a safety net for honest mistakes rather than a security control; running the same check in CI on every pull request closes that gap, and a secret-scanning service on the hosting platform adds a second layer.",[1452,2151,1461,2157,1461,2160,1461,2163,1461,2168,1461,2172,1461,2175,1461,2179,1461,2183,1461,2186,1461,2189,1461,2193,1461,2197,1461,2201,1461,2204,1461,2207,1461,2211],{"viewBox":2152,"role":1455,"ariaLabelledBy":2153,"xmlns":1459,"style":2156},"84 0 592 281",[2154,2155],"pc-layers-t","pc-layers-d","width:100%;max-width:592px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[1463,2158,2159],{"id":2154},"Layers that keep real data out",[1467,2161,2162],{"id":2155},"The pre-commit hook catches mistakes on the developer's machine before a commit exists. The same check in CI catches commits made with no-verify or without the hook installed. Secret scanning on the hosting platform is a final layer for credentials.",[1471,2164],{"x":2165,"y":615,"width":2166,"height":2167,"fill":1476},"84","592","281",[1471,2169],{"x":2170,"y":1480,"width":2171,"height":1491,"rx":1483,"fill":1513,"stroke":1504,"style":1486},"100.0","560.0",[1488,2173,2174],{"x":1541,"y":1589,"style":1518},"Pre-commit hook",[1488,2176,2178],{"x":1541,"y":2177,"style":1497},"61.0","before the commit exists",[41,2180],{"x1":1541,"y1":2181,"x2":1541,"y2":2182,"stroke":1504,"style":1486},"80.0","102.0",[1506,2184],{"points":2185,"fill":1509},"380.0,106.0 375.0,97.0 385.0,97.0",[1471,2187],{"x":2170,"y":2188,"width":2171,"height":1491,"rx":1483,"fill":1561,"stroke":1562,"style":1486},"111.0",[1488,2190,2192],{"x":1541,"y":2191,"style":1566},"136.5","Same check in CI",[1488,2194,2196],{"x":1541,"y":2195,"style":1497},"156.0","catches --no-verify",[41,2198],{"x1":1541,"y1":2199,"x2":1541,"y2":2200,"stroke":1504,"style":1486},"175.0","197.0",[1506,2202],{"points":2203,"fill":1509},"380.0,201.0 375.0,192.0 385.0,192.0",[1471,2205],{"x":2170,"y":2206,"width":2171,"height":1491,"rx":1483,"fill":1584,"stroke":1585,"style":1486},"206.0",[1488,2208,2210],{"x":1541,"y":2209,"style":1590},"231.5","Platform secret scanning",[1488,2212,2214],{"x":1541,"y":2213,"style":1497},"251.0","tokens and keys",[10,2216,2217,2218,23],{},"For large binary files that do belong in the repository, such as a template workbook, the answer is Git LFS rather than a higher limit — see ",[19,2219,2221],{"href":2220},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs\u002F","Store Excel Files in Git with Git LFS",[25,2223,2225],{"id":2224},"common-pitfalls","Common pitfalls",[2227,2228,2229,2237,2251,2261,2267],"ul",{},[2230,2231,2232,2236],"li",{},[2233,2234,2235],"strong",{},"Matching every long number as a card."," Apply the Luhn checksum first.",[2230,2238,2239,2242,2243,358,2245,70,2248,23],{},[2233,2240,2241],{},"Flagging documentation addresses."," Allow ",[14,2244,1449],{},[14,2246,2247],{},"example.org",[14,2249,2250],{},"example.net",[2230,2252,2253,2256,2257,2260],{},[2233,2254,2255],{},"Relying on the hook alone."," ",[14,2258,2259],{},"--no-verify"," skips it; run the same check in CI.",[2230,2262,2263,2266],{},[2233,2264,2265],{},"Deleting a committed file and thinking it is gone."," It stays in history; rewrite or rotate.",[2230,2268,2269,2272,2273,2276],{},[2233,2270,2271],{},"Loading workbooks fully."," Use ",[14,2274,2275],{},"read_only=True"," so the hook stays fast.",[25,2278,2280],{"id":2279},"conclusion","Conclusion",[10,2282,2283,2284,358,2286,70,2288,2291],{},"A pre-commit hook is the cheapest place to stop real data and large spreadsheets from entering a repository. Write a small Python checker that refuses lock files and oversized files and scans cells for emails, National Insurance numbers and Luhn-valid card numbers, register it as a local hook for ",[14,2285,69],{},[14,2287,73],{},[14,2289,2290],{},".csv"," files, keep an explicit reviewed allowlist for fixtures that need realistic values, and run the same check in CI.",[25,2293,2295],{"id":2294},"frequently-asked-questions","Frequently asked questions",[10,2297,2298,2301],{},[2233,2299,2300],{},"How do I stop Excel files being committed to git?","\nAdd a pre-commit hook that receives the staged spreadsheet names and exits with a non-zero status when a file is too large or contains real-looking data.",[10,2303,2304,2307],{},[2233,2305,2306],{},"How can a script detect personal data in an Excel file?","\nRead every cell with openpyxl in read-only mode and match the text against patterns for emails, ID numbers and card numbers, narrowing them with checks such as the Luhn checksum.",[10,2309,2310,2313,2314,2316],{},[2233,2311,2312],{},"Can a developer bypass a pre-commit hook?","\nYes, with ",[14,2315,2148],{},"; run the same check in CI so the bypass is caught before merging.",[10,2318,2319,2322],{},[2233,2320,2321],{},"What should I do if real data was already committed?","\nRemove it from history with a tool such as git filter-repo, force-push, ask everyone to re-clone, and treat the data as exposed.",[25,2324,2326],{"id":2325},"related","Related",[2227,2328,2329,2335,2342,2349],{},[2230,2330,2331,2332,2334],{},"Up one level: ",[19,2333,22],{"href":21}," — reliable, testable projects.",[2230,2336,2337,2341],{},[19,2338,2340],{"href":2339},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fgenerate-realistic-test-data-for-excel-reports-with-faker\u002F","Generate Realistic Test Data for Excel Reports with Faker"," — synthetic data for fixtures.",[2230,2343,2344,2348],{},[19,2345,2347],{"href":2346},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory\u002F","Create Excel Test Fixtures in Memory"," — fixtures without files.",[2230,2350,2351,2355],{},[19,2352,2354],{"href":2353},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-credentials-for-excel-report-scripts-safely\u002F","Store Credentials for Excel Report Scripts Safely"," — the other thing not to commit.",[2357,2358,2359],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s_b0D, html code.shiki .s_b0D{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sXKb-, html code.shiki .sXKb-{--shiki-default:#032F62;--shiki-dark:#F0F3F6}html pre.shiki code .sx1sP, html code.shiki .sx1sP{--shiki-default:#22863A;--shiki-dark:#72F088}",{"title":35,"searchDepth":93,"depth":93,"links":2361},[2362,2363,2364,2365,2366,2367,2368,2369,2370],{"id":27,"depth":93,"text":28},{"id":77,"depth":93,"text":78},{"id":1604,"depth":93,"text":1605},{"id":1719,"depth":93,"text":1720},{"id":2137,"depth":93,"text":2138},{"id":2224,"depth":93,"text":2225},{"id":2279,"depth":93,"text":2280},{"id":2294,"depth":93,"text":2295},{"id":2325,"depth":93,"text":2326},"2026-10-02","Keep real data out of an Excel automation repository: a pre-commit hook in Python that checks every staged spreadsheet before the commit is made — rejecting files over a size limit, Office lock files and workbooks whose cells contain email addresses, card numbers or National Insurance numbers — with an allowlist for synthetic test fixtures, wired up with the pre-commit framework and tested in a scratch repository.","md",[2375,2377,2379,2381],{"q":2300,"a":2376},"Add a pre-commit hook that receives the staged spreadsheet names and exits with a non-zero status when a file is too large or contains real-looking data.",{"q":2306,"a":2378},"Read every cell with openpyxl in read-only mode and match the text against patterns for emails, ID numbers and card numbers, narrowing them with checks such as the Luhn checksum.",{"q":2312,"a":2380},"Yes, with git commit --no-verify; run the same check in CI so the bypass is caught before merging.",{"q":2321,"a":2382},"Remove it from history with a tool such as git filter-repo, force-push, ask everyone to re-clone, and treat the data as exposed.","pre-commit hook excel files",null,{"breadcrumb":2386},[2387,2389,2392],{"name":2388,"item":1099},"Home",{"name":2390,"item":2391},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook",{"title":5,"description":2395},"A Python pre-commit hook for Excel files: size limit, lock files, scanning cells for emails, card numbers with a Luhn check and NI numbers, an allowlist for fixtures, and a test in a scratch git repo.","block-large-or-sensitive-excel-files-with-a-pre-commit-hook","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook\u002Findex",[2399],"reliable-jobs","how-to","rmYFx0bOWFvfIDE8jdZlLNNwBM8v9S6V2jkkgsBeyX8",[2403,2406],{"title":22,"path":2404,"stem":2405,"children":-1},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Findex",{"title":2407,"path":2408,"stem":2409,"children":-1},"Build a Command-Line Tool for Excel Reports with argparse","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse\u002Findex",[2411,2413],{"path":2412,"title":2390},"\u002Fautomating-reporting-workflows",{"path":2404,"title":22},[],[2416,2420,2423,2426,2429,2432],{"path":2417,"title":2418,"kind":2419},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation","Error Handling and Logging in Excel Automation","guide",{"path":2421,"title":2422,"kind":2400},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run","Detect a Scheduled Report That Did Not Run",{"path":2424,"title":2425,"kind":2400},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fignore-excel-lock-files-when-processing-a-folder","Ignore Excel Lock Files When Processing a Folder",{"path":2427,"title":2428,"kind":2400},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file","Log Python Excel Script Output to a File",{"path":2430,"title":2431,"kind":2400},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun","Make an Excel Report Job Safe to Rerun",{"path":2433,"title":2434,"kind":2400},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports","Prevent Formula Injection in Excel Exports from Python",1790972140606]