[{"data":1,"prerenderedAt":2776},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts":3,"surround:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts":2768},{"id":4,"title":5,"body":6,"dateModified":2737,"datePublished":2737,"description":2738,"extension":2739,"faq":2740,"meta":2753,"navigation":233,"path":2760,"seo":2761,"slug":2764,"stem":2765,"type":2766,"__hash__":2767},"docs\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Findex.md","Testing and Packaging Excel Automation Scripts",{"type":7,"value":8,"toc":2723},"minimark",[9,13,22,182,187,190,193,585,595,608,612,620,714,721,1145,1153,1157,1160,1476,1496,1500,1503,1582,1585,1879,1894,1898,1912,1998,2010,2014,2017,2152,2164,2167,2287,2295,2299,2302,2378,2386,2393,2505,2516,2520,2523,2526,2533,2536,2540,2556,2559,2563,2617,2621,2627,2636,2642,2648,2654,2660,2664,2719],[10,11,12],"p",{},"A report script has two lives. In the first it runs on your machine, with your paths, your file open in another window and you watching the traceback. In the second it runs at 06:00 on a server, or on a colleague's laptop, and nobody is watching. Most of the difficulty in Excel automation lives in the gap between those two — not in openpyxl or pandas, but in everything around them: the settings baked into line 12, the sheet name that changed, the library that upgraded itself, the \"just run this file\" instruction that assumes a Python installation.",[10,14,15,16,21],{},"This part of ",[17,18,20],"a",{"href":19},"\u002Fautomating-reporting-workflows\u002F","Automating Reporting Workflows"," covers closing that gap. It is about structure rather than spreadsheet features: splitting the report into a pure transform and a thin I\u002FO shell so both can be tested, asserting against the workbook you actually wrote, giving the script a command line, layering configuration so the scheduled run and the ad-hoc run can differ, and packaging the result so the next person can run it without reading your code.",[23,24,32,33,32,37,32,41,32,48,32,58,32,64,32,69,32,74,32,79,32,84,32,88,32,93,32,98,32,101,32,104,32,109,32,114,32,117,32,120,32,125,32,130,32,134,32,141,32,146,32,150,32,153,32,157,32,160,32,164,32,167,32,173,32,178],"svg",{"viewBox":25,"role":26,"ariaLabelledBy":27,"xmlns":30,"style":31},"0 0 760 236","img",[28,29],"tp-hand-t","tp-hand-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:760px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[34,35,36],"title",{"id":28},"The four steps between a working script and one somebody else can run",[38,39,40],"desc",{"id":29},"A script that works on your machine becomes a handover-ready tool through four additions: tests that assert the workbook's contents, a command line so it takes arguments instead of edits, configuration held outside the code, and a packaged environment with pinned dependencies.",[42,43],"rect",{"x":44,"y":44,"width":45,"height":46,"fill":47},"0","760","236","#ffffff",[42,49],{"x":50,"y":51,"width":52,"height":53,"rx":54,"fill":55,"stroke":56,"style":57},"16","72","128","80","14","#f0f2f5","var(--line,#cdd5e6)","stroke-width:2px",[59,60,63],"text",{"x":53,"y":61,"style":62},"104","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","report.py",[59,65,68],{"x":53,"y":66,"style":67},"126","font-size:11px;fill:var(--muted,#5b6780);text-anchor:middle","works for you",[42,70],{"x":71,"y":51,"width":52,"height":53,"rx":54,"fill":72,"stroke":73,"style":57},"172","#d9f4f1","var(--teal,#0f9488)",[59,75,78],{"x":46,"y":76,"style":77},"98","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","tests",[59,80,83],{"x":46,"y":81,"style":82},"120","font-size:11px;fill:var(--text,#172033);text-anchor:middle","assert the sheets,",[59,85,87],{"x":46,"y":86,"style":82},"138","headers and totals",[42,89],{"x":90,"y":51,"width":52,"height":53,"rx":54,"fill":91,"stroke":92,"style":57},"328","#ebebfd","var(--brand,#5b5cf0)",[59,94,97],{"x":95,"y":76,"style":96},"392","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","command line",[59,99,100],{"x":95,"y":81,"style":82},"arguments instead",[59,102,103],{"x":95,"y":86,"style":82},"of code edits",[42,105],{"x":106,"y":51,"width":52,"height":53,"rx":54,"fill":107,"stroke":108,"style":57},"484","#fdefd8","var(--gold,#b4740a)",[59,110,113],{"x":111,"y":76,"style":112},"548","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","config",[59,115,116],{"x":111,"y":81,"style":82},"paths and recipients",[59,118,119],{"x":111,"y":86,"style":82},"outside the code",[42,121],{"x":122,"y":51,"width":61,"height":53,"rx":54,"fill":123,"stroke":124,"style":57},"640","#5b5cf0","var(--brand-strong,#4338ca)",[59,126,129],{"x":127,"y":61,"style":128},"692","font-size:12.5px;font-weight:700;fill:#ffffff;text-anchor:middle","handover",[59,131,133],{"x":127,"y":66,"style":132},"font-size:11px;fill:rgba(255,255,255,0.9);text-anchor:middle","pinned + packaged",[135,136],"line",{"x1":137,"y1":138,"x2":139,"y2":138,"stroke":140,"style":57},"148","112","166","var(--muted,#5b6780)",[142,143],"polygon",{"points":144,"fill":145},"170,112 160,107 160,117","#5b6780",[135,147],{"x1":148,"y1":138,"x2":149,"y2":138,"stroke":140,"style":57},"304","322",[142,151],{"points":152,"fill":145},"326,112 316,107 316,117",[135,154],{"x1":155,"y1":138,"x2":156,"y2":138,"stroke":140,"style":57},"460","478",[142,158],{"points":159,"fill":145},"482,112 472,107 472,117",[135,161],{"x1":162,"y1":138,"x2":163,"y2":138,"stroke":140,"style":57},"616","634",[142,165],{"points":166,"fill":145},"638,112 628,107 628,117",[59,168,172],{"x":169,"y":170,"style":171},"380","40","font-size:13px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","Each step removes one reason the script only runs on your machine",[59,174,177],{"x":169,"y":175,"style":176},"196","font-size:11.5px;fill:var(--muted,#5b6780);text-anchor:middle","None of these change what the report contains — they change who can produce it,",[59,179,181],{"x":169,"y":180,"style":176},"216","and how quickly a broken run explains itself.",[183,184,186],"h2",{"id":185},"split-the-transform-from-the-input-and-output","Split the transform from the input and output",[10,188,189],{},"Almost every untestable report script has the same shape: one long function that reads a file, filters it, formats a workbook and saves it. There is nothing to assert against, because there is no value in the middle — everything is a side effect.",[10,191,192],{},"The fix is boring and pays for itself immediately. Put the calculation in a function that takes a DataFrame and returns a DataFrame, and keep reading and writing at the edges:",[194,195,200],"pre",{"className":196,"code":197,"language":198,"meta":199,"style":199},"language-python shiki shiki-themes github-light github-dark-high-contrast","# report.py\nfrom pathlib import Path\n\nimport pandas as pd\n\n\ndef summarise(df):\n    \"\"\"Pure: DataFrame in, DataFrame out. No file paths, no I\u002FO, no globals.\"\"\"\n    clean = df.dropna(subset=[\"Region\", \"Amount\"]).copy()\n    clean[\"Amount\"] = pd.to_numeric(clean[\"Amount\"], errors=\"coerce\").fillna(0)\n    out = (clean.groupby(\"Region\", as_index=False)[\"Amount\"]\n                .sum()\n                .sort_values(\"Amount\", ascending=False))\n    out[\"Share\"] = out[\"Amount\"] \u002F out[\"Amount\"].sum()\n    return out\n\n\ndef build_report(source: Path, target: Path) -> Path:\n    \"\"\"Thin shell: read, transform, write. Keep the logic out of here.\"\"\"\n    df = pd.read_excel(source, sheet_name=\"Orders\")\n    summary = summarise(df)\n    with pd.ExcelWriter(target, engine=\"openpyxl\") as writer:\n        summary.to_excel(writer, sheet_name=\"Summary\", index=False)\n        df.to_excel(writer, sheet_name=\"Detail\", index=False)\n    return target\n","python","",[201,202,203,211,228,235,249,254,259,272,279,312,350,381,387,407,437,446,451,456,467,473,494,505,530,554,577],"code",{"__ignoreMap":199},[204,205,207],"span",{"class":135,"line":206},1,[204,208,210],{"class":209},"s-wDw","# report.py\n",[204,212,214,218,222,225],{"class":135,"line":213},2,[204,215,217],{"class":216},"s-kum","from",[204,219,221],{"class":220},"skGVy"," pathlib ",[204,223,224],{"class":216},"import",[204,226,227],{"class":220}," Path\n",[204,229,231],{"class":135,"line":230},3,[204,232,234],{"emptyLinePlaceholder":233},true,"\n",[204,236,238,240,243,246],{"class":135,"line":237},4,[204,239,224],{"class":216},[204,241,242],{"class":220}," pandas ",[204,244,245],{"class":216},"as",[204,247,248],{"class":220}," pd\n",[204,250,252],{"class":135,"line":251},5,[204,253,234],{"emptyLinePlaceholder":233},[204,255,257],{"class":135,"line":256},6,[204,258,234],{"emptyLinePlaceholder":233},[204,260,262,265,269],{"class":135,"line":261},7,[204,263,264],{"class":216},"def",[204,266,268],{"class":267},"s_Opv"," summarise",[204,270,271],{"class":220},"(df):\n",[204,273,275],{"class":135,"line":274},8,[204,276,278],{"class":277},"srMev","    \"\"\"Pure: DataFrame in, DataFrame out. No file paths, no I\u002FO, no globals.\"\"\"\n",[204,280,282,285,288,291,295,297,300,303,306,309],{"class":135,"line":281},9,[204,283,284],{"class":220},"    clean ",[204,286,287],{"class":216},"=",[204,289,290],{"class":220}," df.dropna(",[204,292,294],{"class":293},"sa561","subset",[204,296,287],{"class":216},[204,298,299],{"class":220},"[",[204,301,302],{"class":277},"\"Region\"",[204,304,305],{"class":220},", ",[204,307,308],{"class":277},"\"Amount\"",[204,310,311],{"class":220},"]).copy()\n",[204,313,315,318,320,323,325,328,330,333,336,338,341,344,347],{"class":135,"line":314},10,[204,316,317],{"class":220},"    clean[",[204,319,308],{"class":277},[204,321,322],{"class":220},"] ",[204,324,287],{"class":216},[204,326,327],{"class":220}," pd.to_numeric(clean[",[204,329,308],{"class":277},[204,331,332],{"class":220},"], ",[204,334,335],{"class":293},"errors",[204,337,287],{"class":216},[204,339,340],{"class":277},"\"coerce\"",[204,342,343],{"class":220},").fillna(",[204,345,44],{"class":346},"sP0c6",[204,348,349],{"class":220},")\n",[204,351,353,356,358,361,363,365,368,370,373,376,378],{"class":135,"line":352},11,[204,354,355],{"class":220},"    out ",[204,357,287],{"class":216},[204,359,360],{"class":220}," (clean.groupby(",[204,362,302],{"class":277},[204,364,305],{"class":220},[204,366,367],{"class":293},"as_index",[204,369,287],{"class":216},[204,371,372],{"class":346},"False",[204,374,375],{"class":220},")[",[204,377,308],{"class":277},[204,379,380],{"class":220},"]\n",[204,382,384],{"class":135,"line":383},12,[204,385,386],{"class":220},"                .sum()\n",[204,388,390,393,395,397,400,402,404],{"class":135,"line":389},13,[204,391,392],{"class":220},"                .sort_values(",[204,394,308],{"class":277},[204,396,305],{"class":220},[204,398,399],{"class":293},"ascending",[204,401,287],{"class":216},[204,403,372],{"class":346},[204,405,406],{"class":220},"))\n",[204,408,410,413,416,418,420,423,425,427,430,432,434],{"class":135,"line":409},14,[204,411,412],{"class":220},"    out[",[204,414,415],{"class":277},"\"Share\"",[204,417,322],{"class":220},[204,419,287],{"class":216},[204,421,422],{"class":220}," out[",[204,424,308],{"class":277},[204,426,322],{"class":220},[204,428,429],{"class":216},"\u002F",[204,431,422],{"class":220},[204,433,308],{"class":277},[204,435,436],{"class":220},"].sum()\n",[204,438,440,443],{"class":135,"line":439},15,[204,441,442],{"class":216},"    return",[204,444,445],{"class":220}," out\n",[204,447,449],{"class":135,"line":448},16,[204,450,234],{"emptyLinePlaceholder":233},[204,452,454],{"class":135,"line":453},17,[204,455,234],{"emptyLinePlaceholder":233},[204,457,459,461,464],{"class":135,"line":458},18,[204,460,264],{"class":216},[204,462,463],{"class":267}," build_report",[204,465,466],{"class":220},"(source: Path, target: Path) -> Path:\n",[204,468,470],{"class":135,"line":469},19,[204,471,472],{"class":277},"    \"\"\"Thin shell: read, transform, write. Keep the logic out of here.\"\"\"\n",[204,474,476,479,481,484,487,489,492],{"class":135,"line":475},20,[204,477,478],{"class":220},"    df ",[204,480,287],{"class":216},[204,482,483],{"class":220}," pd.read_excel(source, ",[204,485,486],{"class":293},"sheet_name",[204,488,287],{"class":216},[204,490,491],{"class":277},"\"Orders\"",[204,493,349],{"class":220},[204,495,497,500,502],{"class":135,"line":496},21,[204,498,499],{"class":220},"    summary ",[204,501,287],{"class":216},[204,503,504],{"class":220}," summarise(df)\n",[204,506,508,511,514,517,519,522,525,527],{"class":135,"line":507},22,[204,509,510],{"class":216},"    with",[204,512,513],{"class":220}," pd.ExcelWriter(target, ",[204,515,516],{"class":293},"engine",[204,518,287],{"class":216},[204,520,521],{"class":277},"\"openpyxl\"",[204,523,524],{"class":220},") ",[204,526,245],{"class":216},[204,528,529],{"class":220}," writer:\n",[204,531,533,536,538,540,543,545,548,550,552],{"class":135,"line":532},23,[204,534,535],{"class":220},"        summary.to_excel(writer, ",[204,537,486],{"class":293},[204,539,287],{"class":216},[204,541,542],{"class":277},"\"Summary\"",[204,544,305],{"class":220},[204,546,547],{"class":293},"index",[204,549,287],{"class":216},[204,551,372],{"class":346},[204,553,349],{"class":220},[204,555,557,560,562,564,567,569,571,573,575],{"class":135,"line":556},24,[204,558,559],{"class":220},"        df.to_excel(writer, ",[204,561,486],{"class":293},[204,563,287],{"class":216},[204,565,566],{"class":277},"\"Detail\"",[204,568,305],{"class":220},[204,570,547],{"class":293},[204,572,287],{"class":216},[204,574,372],{"class":346},[204,576,349],{"class":220},[204,578,580,582],{"class":135,"line":579},25,[204,581,442],{"class":216},[204,583,584],{"class":220}," target\n",[10,586,587,590,591,594],{},[201,588,589],{},"summarise"," can now be tested with a five-row DataFrame built in the test file, in milliseconds, with no files involved at all. ",[201,592,593],{},"build_report"," still needs a real workbook to prove itself, but it contains almost no decisions — so the test that covers it is checking the plumbing rather than the arithmetic.",[10,596,597,598,602,603,607],{},"The same split makes the ",[17,599,601],{"href":600},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002F","cleaning"," and ",[17,604,606],{"href":605},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002F","validation"," steps reusable: a pure function can be called from a notebook, a test and the scheduled job without any of them knowing where the data came from.",[183,609,611],{"id":610},"test-the-workbook-not-the-library","Test the workbook, not the library",[10,613,614,615,619],{},"Once the transform is testable, the second question is what to assert about the file. The useful answer is the file's ",[616,617,618],"em",{},"shape and contents at the boundaries"," — sheet names, header row, row count, a total, a number format if the number format matters. Assertions that openpyxl can set a cell value prove nothing about your report.",[23,621,32,627,32,630,32,633,32,637,32,644,32,650,32,655,32,659,32,664,32,668,32,672,32,677,32,680,32,684,32,687,32,692,32,696,32,701,32,705,32,710],{"viewBox":622,"role":26,"ariaLabelledBy":623,"xmlns":30,"style":626},"0 0 740 250",[624,625],"tp-layers-t","tp-layers-d","width:100%;max-width:740px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[34,628,629],{"id":624},"Three layers of tests for a report script, from fastest to slowest",[38,631,632],{"id":625},"Many fast tests cover the pure transform functions with in-memory DataFrames. A smaller number write a real workbook to a temporary directory and assert on its sheets, headers and totals. One or two slow smoke tests run the whole job end to end against a realistic input.",[42,634],{"x":44,"y":44,"width":635,"height":636,"fill":47},"740","250",[42,638],{"x":639,"y":640,"width":641,"height":642,"rx":643,"fill":72,"stroke":73,"style":57},"24","30","440","58","12",[59,645,649],{"x":646,"y":647,"style":648},"46","55","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157)","transform tests — dozens, milliseconds",[59,651,654],{"x":646,"y":652,"style":653},"76","font-size:11.5px;fill:var(--text,#172033)","summarise(df) with a DataFrame built in the test",[42,656],{"x":639,"y":657,"width":658,"height":642,"rx":643,"fill":91,"stroke":92,"style":57},"100","330",[59,660,663],{"x":646,"y":661,"style":662},"125","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca)","workbook tests — a handful, seconds",[59,665,667],{"x":646,"y":666,"style":653},"146","write to tmp_path, reopen, assert sheets and totals",[42,669],{"x":639,"y":670,"width":671,"height":642,"rx":643,"fill":107,"stroke":108,"style":57},"170","220",[59,673,676],{"x":646,"y":674,"style":675},"195","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);","smoke test — one",[59,678,679],{"x":646,"y":180,"style":653},"the real job, realistic input",[135,681],{"x1":682,"y1":170,"x2":682,"y2":683,"stroke":56,"style":57},"500","218",[142,685],{"points":686,"fill":145},"500,224 494,210 506,210",[59,688,691],{"x":689,"y":642,"style":690},"520","font-size:11.5px;font-weight:700;fill:var(--teal-ink,#0b6157)","fast, precise failures",[59,693,695],{"x":689,"y":53,"style":694},"font-size:11px;fill:var(--muted,#5b6780)","names the wrong number",[59,697,700],{"x":689,"y":698,"style":699},"130","font-size:11.5px;font-weight:700;fill:var(--brand-strong,#4338ca)","catches the writing bugs",[59,702,704],{"x":689,"y":703,"style":694},"152","missing sheet, shifted header",[59,706,709],{"x":689,"y":707,"style":708},"200","font-size:11.5px;font-weight:700;fill:var(--gold-ink,#7a4e06)","catches wiring",[59,711,713],{"x":689,"y":712,"style":694},"222","paths, arguments, permissions",[10,715,716,717,720],{},"A workbook test is short because pytest's ",[201,718,719],{},"tmp_path"," fixture hands you a clean directory per test:",[194,722,724],{"className":196,"code":723,"language":198,"meta":199,"style":199},"# tests\u002Ftest_report.py\nimport pandas as pd\nfrom openpyxl import load_workbook\n\nfrom report import build_report, summarise\n\n\ndef sample_frame():\n    return pd.DataFrame({\n        \"Region\": [\"North\", \"South\", \"North\", None],\n        \"Amount\": [100, 250, 50, 999],\n    })\n\n\ndef test_summarise_totals_by_region():\n    out = summarise(sample_frame())\n    assert list(out[\"Region\"]) == [\"South\", \"North\"]\n    assert out.loc[out[\"Region\"] == \"North\", \"Amount\"].item() == 150\n    assert round(out[\"Share\"].sum(), 6) == 1.0\n\n\ndef test_build_report_writes_expected_sheets(tmp_path):\n    source = tmp_path \u002F \"orders.xlsx\"\n    sample_frame().to_excel(source, sheet_name=\"Orders\", index=False)\n\n    target = build_report(source, tmp_path \u002F \"report.xlsx\")\n\n    wb = load_workbook(target)\n    assert wb.sheetnames == [\"Summary\", \"Detail\"]\n    ws = wb[\"Summary\"]\n    assert [c.value for c in ws[1]] == [\"Region\", \"Amount\", \"Share\"]\n    assert ws.max_row == 3                      # header + two regions\n",[201,725,726,731,741,753,757,769,773,777,787,794,822,847,852,856,860,869,878,908,936,960,964,968,978,993,1014,1018,1036,1041,1052,1072,1087,1129],{"__ignoreMap":199},[204,727,728],{"class":135,"line":206},[204,729,730],{"class":209},"# tests\u002Ftest_report.py\n",[204,732,733,735,737,739],{"class":135,"line":213},[204,734,224],{"class":216},[204,736,242],{"class":220},[204,738,245],{"class":216},[204,740,248],{"class":220},[204,742,743,745,748,750],{"class":135,"line":230},[204,744,217],{"class":216},[204,746,747],{"class":220}," openpyxl ",[204,749,224],{"class":216},[204,751,752],{"class":220}," load_workbook\n",[204,754,755],{"class":135,"line":237},[204,756,234],{"emptyLinePlaceholder":233},[204,758,759,761,764,766],{"class":135,"line":251},[204,760,217],{"class":216},[204,762,763],{"class":220}," report ",[204,765,224],{"class":216},[204,767,768],{"class":220}," build_report, summarise\n",[204,770,771],{"class":135,"line":256},[204,772,234],{"emptyLinePlaceholder":233},[204,774,775],{"class":135,"line":261},[204,776,234],{"emptyLinePlaceholder":233},[204,778,779,781,784],{"class":135,"line":274},[204,780,264],{"class":216},[204,782,783],{"class":267}," sample_frame",[204,785,786],{"class":220},"():\n",[204,788,789,791],{"class":135,"line":281},[204,790,442],{"class":216},[204,792,793],{"class":220}," pd.DataFrame({\n",[204,795,796,799,802,805,807,810,812,814,816,819],{"class":135,"line":314},[204,797,798],{"class":277},"        \"Region\"",[204,800,801],{"class":220},": [",[204,803,804],{"class":277},"\"North\"",[204,806,305],{"class":220},[204,808,809],{"class":277},"\"South\"",[204,811,305],{"class":220},[204,813,804],{"class":277},[204,815,305],{"class":220},[204,817,818],{"class":346},"None",[204,820,821],{"class":220},"],\n",[204,823,824,827,829,831,833,835,837,840,842,845],{"class":135,"line":352},[204,825,826],{"class":277},"        \"Amount\"",[204,828,801],{"class":220},[204,830,657],{"class":346},[204,832,305],{"class":220},[204,834,636],{"class":346},[204,836,305],{"class":220},[204,838,839],{"class":346},"50",[204,841,305],{"class":220},[204,843,844],{"class":346},"999",[204,846,821],{"class":220},[204,848,849],{"class":135,"line":383},[204,850,851],{"class":220},"    })\n",[204,853,854],{"class":135,"line":389},[204,855,234],{"emptyLinePlaceholder":233},[204,857,858],{"class":135,"line":409},[204,859,234],{"emptyLinePlaceholder":233},[204,861,862,864,867],{"class":135,"line":439},[204,863,264],{"class":216},[204,865,866],{"class":267}," test_summarise_totals_by_region",[204,868,786],{"class":220},[204,870,871,873,875],{"class":135,"line":448},[204,872,355],{"class":220},[204,874,287],{"class":216},[204,876,877],{"class":220}," summarise(sample_frame())\n",[204,879,880,883,886,889,891,894,897,900,902,904,906],{"class":135,"line":453},[204,881,882],{"class":216},"    assert",[204,884,885],{"class":346}," list",[204,887,888],{"class":220},"(out[",[204,890,302],{"class":277},[204,892,893],{"class":220},"]) ",[204,895,896],{"class":216},"==",[204,898,899],{"class":220}," [",[204,901,809],{"class":277},[204,903,305],{"class":220},[204,905,804],{"class":277},[204,907,380],{"class":220},[204,909,910,912,915,917,919,921,924,926,928,931,933],{"class":135,"line":458},[204,911,882],{"class":216},[204,913,914],{"class":220}," out.loc[out[",[204,916,302],{"class":277},[204,918,322],{"class":220},[204,920,896],{"class":216},[204,922,923],{"class":277}," \"North\"",[204,925,305],{"class":220},[204,927,308],{"class":277},[204,929,930],{"class":220},"].item() ",[204,932,896],{"class":216},[204,934,935],{"class":346}," 150\n",[204,937,938,940,943,945,947,950,953,955,957],{"class":135,"line":469},[204,939,882],{"class":216},[204,941,942],{"class":346}," round",[204,944,888],{"class":220},[204,946,415],{"class":277},[204,948,949],{"class":220},"].sum(), ",[204,951,952],{"class":346},"6",[204,954,524],{"class":220},[204,956,896],{"class":216},[204,958,959],{"class":346}," 1.0\n",[204,961,962],{"class":135,"line":475},[204,963,234],{"emptyLinePlaceholder":233},[204,965,966],{"class":135,"line":496},[204,967,234],{"emptyLinePlaceholder":233},[204,969,970,972,975],{"class":135,"line":507},[204,971,264],{"class":216},[204,973,974],{"class":267}," test_build_report_writes_expected_sheets",[204,976,977],{"class":220},"(tmp_path):\n",[204,979,980,983,985,988,990],{"class":135,"line":532},[204,981,982],{"class":220},"    source ",[204,984,287],{"class":216},[204,986,987],{"class":220}," tmp_path ",[204,989,429],{"class":216},[204,991,992],{"class":277}," \"orders.xlsx\"\n",[204,994,995,998,1000,1002,1004,1006,1008,1010,1012],{"class":135,"line":556},[204,996,997],{"class":220},"    sample_frame().to_excel(source, ",[204,999,486],{"class":293},[204,1001,287],{"class":216},[204,1003,491],{"class":277},[204,1005,305],{"class":220},[204,1007,547],{"class":293},[204,1009,287],{"class":216},[204,1011,372],{"class":346},[204,1013,349],{"class":220},[204,1015,1016],{"class":135,"line":579},[204,1017,234],{"emptyLinePlaceholder":233},[204,1019,1021,1024,1026,1029,1031,1034],{"class":135,"line":1020},26,[204,1022,1023],{"class":220},"    target ",[204,1025,287],{"class":216},[204,1027,1028],{"class":220}," build_report(source, tmp_path ",[204,1030,429],{"class":216},[204,1032,1033],{"class":277}," \"report.xlsx\"",[204,1035,349],{"class":220},[204,1037,1039],{"class":135,"line":1038},27,[204,1040,234],{"emptyLinePlaceholder":233},[204,1042,1044,1047,1049],{"class":135,"line":1043},28,[204,1045,1046],{"class":220},"    wb ",[204,1048,287],{"class":216},[204,1050,1051],{"class":220}," load_workbook(target)\n",[204,1053,1055,1057,1060,1062,1064,1066,1068,1070],{"class":135,"line":1054},29,[204,1056,882],{"class":216},[204,1058,1059],{"class":220}," wb.sheetnames ",[204,1061,896],{"class":216},[204,1063,899],{"class":220},[204,1065,542],{"class":277},[204,1067,305],{"class":220},[204,1069,566],{"class":277},[204,1071,380],{"class":220},[204,1073,1075,1078,1080,1083,1085],{"class":135,"line":1074},30,[204,1076,1077],{"class":220},"    ws ",[204,1079,287],{"class":216},[204,1081,1082],{"class":220}," wb[",[204,1084,542],{"class":277},[204,1086,380],{"class":220},[204,1088,1090,1092,1095,1098,1101,1104,1107,1110,1113,1115,1117,1119,1121,1123,1125,1127],{"class":135,"line":1089},31,[204,1091,882],{"class":216},[204,1093,1094],{"class":220}," [c.value ",[204,1096,1097],{"class":216},"for",[204,1099,1100],{"class":220}," c ",[204,1102,1103],{"class":216},"in",[204,1105,1106],{"class":220}," ws[",[204,1108,1109],{"class":346},"1",[204,1111,1112],{"class":220},"]] ",[204,1114,896],{"class":216},[204,1116,899],{"class":220},[204,1118,302],{"class":277},[204,1120,305],{"class":220},[204,1122,308],{"class":277},[204,1124,305],{"class":220},[204,1126,415],{"class":277},[204,1128,380],{"class":220},[204,1130,1132,1134,1137,1139,1142],{"class":135,"line":1131},32,[204,1133,882],{"class":216},[204,1135,1136],{"class":220}," ws.max_row ",[204,1138,896],{"class":216},[204,1140,1141],{"class":346}," 3",[204,1143,1144],{"class":209},"                      # header + two regions\n",[10,1146,1147,1148,1152],{},"Three assertions cover the failures that actually happen: a renamed sheet, a header row that shifted because someone added a title, and a row count that collapsed because a filter went wrong. ",[17,1149,1151],{"href":1150},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-output-with-pytest\u002F","Test Excel Output with pytest"," works through fixtures, floating-point comparisons and testing formatting in detail.",[183,1154,1156],{"id":1155},"give-the-script-a-command-line","Give the script a command line",[10,1158,1159],{},"The moment a second person needs to run the report, the constants at the top of the file become the problem. A command line is fifteen lines and removes the whole class of \"edit line 12, run, remember to change it back\" mistakes:",[194,1161,1163],{"className":196,"code":1162,"language":198,"meta":199,"style":199},"import argparse\nfrom pathlib import Path\n\n\ndef parse_args(argv=None):\n    parser = argparse.ArgumentParser(description=\"Build the monthly regional report.\")\n    parser.add_argument(\"source\", type=Path, help=\"input workbook (.xlsx)\")\n    parser.add_argument(\"-o\", \"--output\", type=Path, default=Path(\"report.xlsx\"))\n    parser.add_argument(\"--sheet\", default=\"Orders\", help=\"sheet to read\")\n    parser.add_argument(\"--dry-run\", action=\"store_true\",\n                        help=\"do everything except write the file\")\n    return parser.parse_args(argv)\n\n\ndef main(argv=None):\n    args = parse_args(argv)\n    if not args.source.is_file():\n        raise SystemExit(f\"no such input file: {args.source}\")\n    ...\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n",[201,1164,1165,1172,1182,1186,1190,1207,1227,1255,1288,1314,1334,1346,1353,1357,1361,1376,1386,1397,1429,1434,1441,1445,1449,1466],{"__ignoreMap":199},[204,1166,1167,1169],{"class":135,"line":206},[204,1168,224],{"class":216},[204,1170,1171],{"class":220}," argparse\n",[204,1173,1174,1176,1178,1180],{"class":135,"line":213},[204,1175,217],{"class":216},[204,1177,221],{"class":220},[204,1179,224],{"class":216},[204,1181,227],{"class":220},[204,1183,1184],{"class":135,"line":230},[204,1185,234],{"emptyLinePlaceholder":233},[204,1187,1188],{"class":135,"line":237},[204,1189,234],{"emptyLinePlaceholder":233},[204,1191,1192,1194,1197,1200,1202,1204],{"class":135,"line":251},[204,1193,264],{"class":216},[204,1195,1196],{"class":267}," parse_args",[204,1198,1199],{"class":220},"(argv",[204,1201,287],{"class":216},[204,1203,818],{"class":346},[204,1205,1206],{"class":220},"):\n",[204,1208,1209,1212,1214,1217,1220,1222,1225],{"class":135,"line":256},[204,1210,1211],{"class":220},"    parser ",[204,1213,287],{"class":216},[204,1215,1216],{"class":220}," argparse.ArgumentParser(",[204,1218,1219],{"class":293},"description",[204,1221,287],{"class":216},[204,1223,1224],{"class":277},"\"Build the monthly regional report.\"",[204,1226,349],{"class":220},[204,1228,1229,1232,1235,1237,1240,1242,1245,1248,1250,1253],{"class":135,"line":261},[204,1230,1231],{"class":220},"    parser.add_argument(",[204,1233,1234],{"class":277},"\"source\"",[204,1236,305],{"class":220},[204,1238,1239],{"class":293},"type",[204,1241,287],{"class":216},[204,1243,1244],{"class":220},"Path, ",[204,1246,1247],{"class":293},"help",[204,1249,287],{"class":216},[204,1251,1252],{"class":277},"\"input workbook (.xlsx)\"",[204,1254,349],{"class":220},[204,1256,1257,1259,1262,1264,1267,1269,1271,1273,1275,1278,1280,1283,1286],{"class":135,"line":274},[204,1258,1231],{"class":220},[204,1260,1261],{"class":277},"\"-o\"",[204,1263,305],{"class":220},[204,1265,1266],{"class":277},"\"--output\"",[204,1268,305],{"class":220},[204,1270,1239],{"class":293},[204,1272,287],{"class":216},[204,1274,1244],{"class":220},[204,1276,1277],{"class":293},"default",[204,1279,287],{"class":216},[204,1281,1282],{"class":220},"Path(",[204,1284,1285],{"class":277},"\"report.xlsx\"",[204,1287,406],{"class":220},[204,1289,1290,1292,1295,1297,1299,1301,1303,1305,1307,1309,1312],{"class":135,"line":281},[204,1291,1231],{"class":220},[204,1293,1294],{"class":277},"\"--sheet\"",[204,1296,305],{"class":220},[204,1298,1277],{"class":293},[204,1300,287],{"class":216},[204,1302,491],{"class":277},[204,1304,305],{"class":220},[204,1306,1247],{"class":293},[204,1308,287],{"class":216},[204,1310,1311],{"class":277},"\"sheet to read\"",[204,1313,349],{"class":220},[204,1315,1316,1318,1321,1323,1326,1328,1331],{"class":135,"line":314},[204,1317,1231],{"class":220},[204,1319,1320],{"class":277},"\"--dry-run\"",[204,1322,305],{"class":220},[204,1324,1325],{"class":293},"action",[204,1327,287],{"class":216},[204,1329,1330],{"class":277},"\"store_true\"",[204,1332,1333],{"class":220},",\n",[204,1335,1336,1339,1341,1344],{"class":135,"line":352},[204,1337,1338],{"class":293},"                        help",[204,1340,287],{"class":216},[204,1342,1343],{"class":277},"\"do everything except write the file\"",[204,1345,349],{"class":220},[204,1347,1348,1350],{"class":135,"line":383},[204,1349,442],{"class":216},[204,1351,1352],{"class":220}," parser.parse_args(argv)\n",[204,1354,1355],{"class":135,"line":389},[204,1356,234],{"emptyLinePlaceholder":233},[204,1358,1359],{"class":135,"line":409},[204,1360,234],{"emptyLinePlaceholder":233},[204,1362,1363,1365,1368,1370,1372,1374],{"class":135,"line":439},[204,1364,264],{"class":216},[204,1366,1367],{"class":267}," main",[204,1369,1199],{"class":220},[204,1371,287],{"class":216},[204,1373,818],{"class":346},[204,1375,1206],{"class":220},[204,1377,1378,1381,1383],{"class":135,"line":448},[204,1379,1380],{"class":220},"    args ",[204,1382,287],{"class":216},[204,1384,1385],{"class":220}," parse_args(argv)\n",[204,1387,1388,1391,1394],{"class":135,"line":453},[204,1389,1390],{"class":216},"    if",[204,1392,1393],{"class":216}," not",[204,1395,1396],{"class":220}," args.source.is_file():\n",[204,1398,1399,1402,1405,1408,1411,1414,1418,1421,1424,1427],{"class":135,"line":458},[204,1400,1401],{"class":216},"        raise",[204,1403,1404],{"class":346}," SystemExit",[204,1406,1407],{"class":220},"(",[204,1409,1410],{"class":216},"f",[204,1412,1413],{"class":277},"\"no such input file: ",[204,1415,1417],{"class":1416},"sSjpA","{",[204,1419,1420],{"class":220},"args.source",[204,1422,1423],{"class":1416},"}",[204,1425,1426],{"class":277},"\"",[204,1428,349],{"class":220},[204,1430,1431],{"class":135,"line":469},[204,1432,1433],{"class":346},"    ...\n",[204,1435,1436,1438],{"class":135,"line":475},[204,1437,442],{"class":216},[204,1439,1440],{"class":346}," 0\n",[204,1442,1443],{"class":135,"line":496},[204,1444,234],{"emptyLinePlaceholder":233},[204,1446,1447],{"class":135,"line":507},[204,1448,234],{"emptyLinePlaceholder":233},[204,1450,1451,1454,1457,1460,1463],{"class":135,"line":532},[204,1452,1453],{"class":216},"if",[204,1455,1456],{"class":346}," __name__",[204,1458,1459],{"class":216}," ==",[204,1461,1462],{"class":277}," \"__main__\"",[204,1464,1465],{"class":220},":\n",[204,1467,1468,1471,1473],{"class":135,"line":556},[204,1469,1470],{"class":216},"    raise",[204,1472,1404],{"class":346},[204,1474,1475],{"class":220},"(main())\n",[10,1477,1478,1479,1482,1483,1486,1487,1490,1491,1495],{},"Two details matter more than the argument list. ",[201,1480,1481],{},"parse_args(argv=None)"," taking an argument list makes the parser itself testable — ",[201,1484,1485],{},"parse_args([\"in.xlsx\", \"--dry-run\"])"," needs no subprocess. And returning an exit code from ",[201,1488,1489],{},"main"," gives cron and Task Scheduler something to act on; a job that always exits zero cannot be monitored. ",[17,1492,1494],{"href":1493},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse\u002F","Build a Command-Line Tool for Excel Reports with argparse"," covers subcommands, date arguments and exit codes.",[183,1497,1499],{"id":1498},"layer-the-configuration","Layer the configuration",[10,1501,1502],{},"Flags are right for the things that change per run. They are wrong for the twenty stable settings a report carries — recipients, thresholds, column mappings, the network path to the source — which belong in a file that a non-programmer can edit and that version control can track.",[23,1504,32,1509,32,1512,32,1515,32,1518,32,1524,32,1529,32,1534,32,1536,32,1541,32,1544,32,1547,32,1551,32,1554,32,1557,32,1561,32,1565,32,1569,32,1572,32,1578],{"viewBox":1505,"role":26,"ariaLabelledBy":1506,"xmlns":30,"style":626},"0 0 740 246",[1507,1508],"tp-cfg-t","tp-cfg-d",[34,1510,1511],{"id":1507},"Four configuration layers, each overriding the one below it",[38,1513,1514],{"id":1508},"Defaults in code sit at the bottom, overridden by a checked-in config file, then by environment variables holding secrets, then by command-line flags for one-off runs. The value that wins is the one from the highest layer that sets it.",[42,1516],{"x":44,"y":44,"width":635,"height":1517,"fill":47},"246",[42,1519],{"x":1520,"y":1521,"width":641,"height":1522,"rx":1523,"fill":91,"stroke":92,"style":57},"150","26","44","10",[59,1525,1528],{"x":1526,"y":1527,"style":662},"176","54","command-line flags",[59,1530,1533],{"x":1531,"y":1527,"style":1532},"564","font-size:11.5px;fill:var(--text,#172033);text-anchor:end","--month 2026-07, one run only",[42,1535],{"x":1520,"y":53,"width":641,"height":1522,"rx":1523,"fill":107,"stroke":108,"style":57},[59,1537,1540],{"x":1526,"y":1538,"style":1539},"108","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06)","environment variables",[59,1542,1543],{"x":1531,"y":1538,"style":1532},"secrets, per-machine paths",[42,1545],{"x":1520,"y":1546,"width":641,"height":1522,"rx":1523,"fill":72,"stroke":73,"style":57},"134",[59,1548,1550],{"x":1526,"y":1549,"style":648},"162","config file",[59,1552,1553],{"x":1531,"y":1549,"style":1532},"recipients, thresholds, columns",[42,1555],{"x":1520,"y":1556,"width":641,"height":1522,"rx":1523,"fill":55,"stroke":56,"style":57},"188",[59,1558,1560],{"x":1526,"y":180,"style":1559},"font-size:12.5px;font-weight:700;fill:var(--text,#172033)","defaults in code",[59,1562,1564],{"x":1531,"y":180,"style":1563},"font-size:11.5px;fill:var(--muted,#5b6780);text-anchor:end","so a bare run still works",[135,1566],{"x1":657,"y1":1567,"x2":657,"y2":1568,"stroke":140,"style":57},"212","52",[142,1570],{"points":1571,"fill":145},"100,44 94,58 106,58",[59,1573,1577],{"x":652,"y":1574,"style":1575,"transform":1576},"140","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","rotate(-90 76 140)","higher layer wins",[59,1579,1581],{"x":122,"y":1574,"style":176,"transform":1580},"rotate(-90 640 140)","edited less often",[10,1583,1584],{},"The loader is a dictionary merge, in the order that gives you the precedence above:",[194,1586,1588],{"className":196,"code":1587,"language":198,"meta":199,"style":199},"import json\nimport os\nfrom pathlib import Path\n\nDEFAULTS = {\n    \"sheet\": \"Orders\",\n    \"output\": \"report.xlsx\",\n    \"recipients\": [],\n    \"min_amount\": 0,\n}\n\n\ndef load_config(path=None, env=os.environ, overrides=None):\n    \"\"\"defaults \u003C config file \u003C REPORT_* env vars \u003C explicit overrides (flags).\"\"\"\n    config = dict(DEFAULTS)\n\n    if path and Path(path).is_file():\n        config.update(json.loads(Path(path).read_text()))\n\n    for key in list(config):\n        env_key = f\"REPORT_{key.upper()}\"\n        if env_key in env:\n            config[key] = env[env_key]\n\n    for key, value in (overrides or {}).items():\n        if value is not None:                   # argparse default of None means \"not given\"\n            config[key] = value\n    return config\n",[201,1589,1590,1597,1604,1614,1618,1629,1641,1652,1660,1671,1676,1680,1684,1712,1717,1733,1737,1750,1755,1759,1774,1797,1810,1820,1824,1842,1863,1872],{"__ignoreMap":199},[204,1591,1592,1594],{"class":135,"line":206},[204,1593,224],{"class":216},[204,1595,1596],{"class":220}," json\n",[204,1598,1599,1601],{"class":135,"line":213},[204,1600,224],{"class":216},[204,1602,1603],{"class":220}," os\n",[204,1605,1606,1608,1610,1612],{"class":135,"line":230},[204,1607,217],{"class":216},[204,1609,221],{"class":220},[204,1611,224],{"class":216},[204,1613,227],{"class":220},[204,1615,1616],{"class":135,"line":237},[204,1617,234],{"emptyLinePlaceholder":233},[204,1619,1620,1623,1626],{"class":135,"line":251},[204,1621,1622],{"class":346},"DEFAULTS",[204,1624,1625],{"class":216}," =",[204,1627,1628],{"class":220}," {\n",[204,1630,1631,1634,1637,1639],{"class":135,"line":256},[204,1632,1633],{"class":277},"    \"sheet\"",[204,1635,1636],{"class":220},": ",[204,1638,491],{"class":277},[204,1640,1333],{"class":220},[204,1642,1643,1646,1648,1650],{"class":135,"line":261},[204,1644,1645],{"class":277},"    \"output\"",[204,1647,1636],{"class":220},[204,1649,1285],{"class":277},[204,1651,1333],{"class":220},[204,1653,1654,1657],{"class":135,"line":274},[204,1655,1656],{"class":277},"    \"recipients\"",[204,1658,1659],{"class":220},": [],\n",[204,1661,1662,1665,1667,1669],{"class":135,"line":281},[204,1663,1664],{"class":277},"    \"min_amount\"",[204,1666,1636],{"class":220},[204,1668,44],{"class":346},[204,1670,1333],{"class":220},[204,1672,1673],{"class":135,"line":314},[204,1674,1675],{"class":220},"}\n",[204,1677,1678],{"class":135,"line":352},[204,1679,234],{"emptyLinePlaceholder":233},[204,1681,1682],{"class":135,"line":383},[204,1683,234],{"emptyLinePlaceholder":233},[204,1685,1686,1688,1691,1694,1696,1698,1701,1703,1706,1708,1710],{"class":135,"line":389},[204,1687,264],{"class":216},[204,1689,1690],{"class":267}," load_config",[204,1692,1693],{"class":220},"(path",[204,1695,287],{"class":216},[204,1697,818],{"class":346},[204,1699,1700],{"class":220},", env",[204,1702,287],{"class":216},[204,1704,1705],{"class":220},"os.environ, overrides",[204,1707,287],{"class":216},[204,1709,818],{"class":346},[204,1711,1206],{"class":220},[204,1713,1714],{"class":135,"line":409},[204,1715,1716],{"class":277},"    \"\"\"defaults \u003C config file \u003C REPORT_* env vars \u003C explicit overrides (flags).\"\"\"\n",[204,1718,1719,1722,1724,1727,1729,1731],{"class":135,"line":439},[204,1720,1721],{"class":220},"    config ",[204,1723,287],{"class":216},[204,1725,1726],{"class":346}," dict",[204,1728,1407],{"class":220},[204,1730,1622],{"class":346},[204,1732,349],{"class":220},[204,1734,1735],{"class":135,"line":448},[204,1736,234],{"emptyLinePlaceholder":233},[204,1738,1739,1741,1744,1747],{"class":135,"line":453},[204,1740,1390],{"class":216},[204,1742,1743],{"class":220}," path ",[204,1745,1746],{"class":216},"and",[204,1748,1749],{"class":220}," Path(path).is_file():\n",[204,1751,1752],{"class":135,"line":458},[204,1753,1754],{"class":220},"        config.update(json.loads(Path(path).read_text()))\n",[204,1756,1757],{"class":135,"line":469},[204,1758,234],{"emptyLinePlaceholder":233},[204,1760,1761,1764,1767,1769,1771],{"class":135,"line":475},[204,1762,1763],{"class":216},"    for",[204,1765,1766],{"class":220}," key ",[204,1768,1103],{"class":216},[204,1770,885],{"class":346},[204,1772,1773],{"class":220},"(config):\n",[204,1775,1776,1779,1781,1784,1787,1789,1792,1794],{"class":135,"line":496},[204,1777,1778],{"class":220},"        env_key ",[204,1780,287],{"class":216},[204,1782,1783],{"class":216}," f",[204,1785,1786],{"class":277},"\"REPORT_",[204,1788,1417],{"class":1416},[204,1790,1791],{"class":220},"key.upper()",[204,1793,1423],{"class":1416},[204,1795,1796],{"class":277},"\"\n",[204,1798,1799,1802,1805,1807],{"class":135,"line":507},[204,1800,1801],{"class":216},"        if",[204,1803,1804],{"class":220}," env_key ",[204,1806,1103],{"class":216},[204,1808,1809],{"class":220}," env:\n",[204,1811,1812,1815,1817],{"class":135,"line":532},[204,1813,1814],{"class":220},"            config[key] ",[204,1816,287],{"class":216},[204,1818,1819],{"class":220}," env[env_key]\n",[204,1821,1822],{"class":135,"line":556},[204,1823,234],{"emptyLinePlaceholder":233},[204,1825,1826,1828,1831,1833,1836,1839],{"class":135,"line":579},[204,1827,1763],{"class":216},[204,1829,1830],{"class":220}," key, value ",[204,1832,1103],{"class":216},[204,1834,1835],{"class":220}," (overrides ",[204,1837,1838],{"class":216},"or",[204,1840,1841],{"class":220}," {}).items():\n",[204,1843,1844,1846,1849,1852,1854,1857,1860],{"class":135,"line":1020},[204,1845,1801],{"class":216},[204,1847,1848],{"class":220}," value ",[204,1850,1851],{"class":216},"is",[204,1853,1393],{"class":216},[204,1855,1856],{"class":346}," None",[204,1858,1859],{"class":220},":                   ",[204,1861,1862],{"class":209},"# argparse default of None means \"not given\"\n",[204,1864,1865,1867,1869],{"class":135,"line":1038},[204,1866,1814],{"class":220},[204,1868,287],{"class":216},[204,1870,1871],{"class":220}," value\n",[204,1873,1874,1876],{"class":135,"line":1043},[204,1875,442],{"class":216},[204,1877,1878],{"class":220}," config\n",[10,1880,1881,1882,1885,1886,1888,1889,1893],{},"The ",[201,1883,1884],{},"if value is not None"," guard is what makes flags composable: a flag that was not passed must not overwrite the config file with ",[201,1887,818],{},". Keeping secrets in environment variables rather than the file is the other rule worth enforcing — an SMTP password in a JSON file checked into a repository is the single most common way a reporting job leaks a credential. ",[17,1890,1892],{"href":1891},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file\u002F","Keep Excel Report Settings in a Config File"," covers validation, per-environment files and safe defaults.",[183,1895,1897],{"id":1896},"pin-what-the-job-depends-on","Pin what the job depends on",[10,1899,1900,1901,1903,1904,1907,1908,1911],{},"An Excel report is unusually sensitive to library versions. pandas has changed the default engine, the ",[201,1902,486],{}," return type and several ",[201,1905,1906],{},"read_excel"," behaviours across minor releases; openpyxl has changed how it reports empty cells and merged ranges. A scheduled job installed with an unpinned ",[201,1909,1910],{},"pip install pandas openpyxl"," is a job that upgrades itself at 06:00 one morning without being asked.",[194,1913,1917],{"className":1914,"code":1915,"language":1916,"meta":199,"style":199},"language-bash shiki shiki-themes github-light github-dark-high-contrast","# Freeze exactly what a working run used\npython -m venv .venv\n.venv\u002Fbin\u002Fpip install pandas openpyxl xlsxwriter\n.venv\u002Fbin\u002Fpip freeze > requirements.txt\n\n# On the server, or the next machine\npython -m venv .venv\n.venv\u002Fbin\u002Fpip install -r requirements.txt\n","bash",[201,1918,1919,1924,1938,1955,1968,1972,1977,1987],{"__ignoreMap":199},[204,1920,1921],{"class":135,"line":206},[204,1922,1923],{"class":209},"# Freeze exactly what a working run used\n",[204,1925,1926,1929,1932,1935],{"class":135,"line":213},[204,1927,198],{"class":1928},"sMTad",[204,1930,1931],{"class":346}," -m",[204,1933,1934],{"class":277}," venv",[204,1936,1937],{"class":277}," .venv\n",[204,1939,1940,1943,1946,1949,1952],{"class":135,"line":230},[204,1941,1942],{"class":1928},".venv\u002Fbin\u002Fpip",[204,1944,1945],{"class":277}," install",[204,1947,1948],{"class":277}," pandas",[204,1950,1951],{"class":277}," openpyxl",[204,1953,1954],{"class":277}," xlsxwriter\n",[204,1956,1957,1959,1962,1965],{"class":135,"line":237},[204,1958,1942],{"class":1928},[204,1960,1961],{"class":277}," freeze",[204,1963,1964],{"class":216}," >",[204,1966,1967],{"class":277}," requirements.txt\n",[204,1969,1970],{"class":135,"line":251},[204,1971,234],{"emptyLinePlaceholder":233},[204,1973,1974],{"class":135,"line":256},[204,1975,1976],{"class":209},"# On the server, or the next machine\n",[204,1978,1979,1981,1983,1985],{"class":135,"line":261},[204,1980,198],{"class":1928},[204,1982,1931],{"class":346},[204,1984,1934],{"class":277},[204,1986,1937],{"class":277},[204,1988,1989,1991,1993,1996],{"class":135,"line":274},[204,1990,1942],{"class":1928},[204,1992,1945],{"class":277},[204,1994,1995],{"class":346}," -r",[204,1997,1967],{"class":277},[10,1999,2000,2001,2004,2005,2009],{},"Point the scheduler at ",[201,2002,2003],{},".venv\u002Fbin\u002Fpython"," — the interpreter inside the environment — rather than activating anything, because ",[17,2006,2008],{"href":2007},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002F","cron and Task Scheduler"," run with a minimal shell that never sources your profile. An absolute path to a pinned interpreter removes the most common cause of \"it runs in my terminal but not on a schedule\".",[183,2011,2013],{"id":2012},"run-the-tests-where-they-can-stop-a-bad-change","Run the tests where they can stop a bad change",[10,2015,2016],{},"A suite that only runs when someone remembers is a suite that stops running. Report tests are fast — small workbooks written to a temporary directory — so they belong on every change, which in practice means a continuous-integration job of about a dozen lines:",[194,2018,2022],{"className":2019,"code":2020,"language":2021,"meta":199,"style":199},"language-yaml shiki shiki-themes github-light github-dark-high-contrast","# .github\u002Fworkflows\u002Ftests.yml\nname: tests\non: [push, pull_request]\njobs:\n  pytest:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - uses: actions\u002Fsetup-python@v5\n        with:\n          python-version: \"3.11\"\n      - run: pip install -r requirements.txt -r requirements-dev.txt\n      - run: pytest -q\n","yaml",[201,2023,2024,2029,2040,2057,2064,2071,2081,2088,2101,2112,2119,2129,2141],{"__ignoreMap":199},[204,2025,2026],{"class":135,"line":206},[204,2027,2028],{"class":209},"# .github\u002Fworkflows\u002Ftests.yml\n",[204,2030,2031,2035,2037],{"class":135,"line":213},[204,2032,2034],{"class":2033},"sx1sP","name",[204,2036,1636],{"class":220},[204,2038,2039],{"class":277},"tests\n",[204,2041,2042,2045,2047,2050,2052,2055],{"class":135,"line":230},[204,2043,2044],{"class":346},"on",[204,2046,801],{"class":220},[204,2048,2049],{"class":277},"push",[204,2051,305],{"class":220},[204,2053,2054],{"class":277},"pull_request",[204,2056,380],{"class":220},[204,2058,2059,2062],{"class":135,"line":237},[204,2060,2061],{"class":2033},"jobs",[204,2063,1465],{"class":220},[204,2065,2066,2069],{"class":135,"line":251},[204,2067,2068],{"class":2033},"  pytest",[204,2070,1465],{"class":220},[204,2072,2073,2076,2078],{"class":135,"line":256},[204,2074,2075],{"class":2033},"    runs-on",[204,2077,1636],{"class":220},[204,2079,2080],{"class":277},"ubuntu-latest\n",[204,2082,2083,2086],{"class":135,"line":261},[204,2084,2085],{"class":2033},"    steps",[204,2087,1465],{"class":220},[204,2089,2090,2093,2096,2098],{"class":135,"line":274},[204,2091,2092],{"class":220},"      - ",[204,2094,2095],{"class":2033},"uses",[204,2097,1636],{"class":220},[204,2099,2100],{"class":277},"actions\u002Fcheckout@v4\n",[204,2102,2103,2105,2107,2109],{"class":135,"line":281},[204,2104,2092],{"class":220},[204,2106,2095],{"class":2033},[204,2108,1636],{"class":220},[204,2110,2111],{"class":277},"actions\u002Fsetup-python@v5\n",[204,2113,2114,2117],{"class":135,"line":314},[204,2115,2116],{"class":2033},"        with",[204,2118,1465],{"class":220},[204,2120,2121,2124,2126],{"class":135,"line":352},[204,2122,2123],{"class":2033},"          python-version",[204,2125,1636],{"class":220},[204,2127,2128],{"class":277},"\"3.11\"\n",[204,2130,2131,2133,2136,2138],{"class":135,"line":383},[204,2132,2092],{"class":220},[204,2134,2135],{"class":2033},"run",[204,2137,1636],{"class":220},[204,2139,2140],{"class":277},"pip install -r requirements.txt -r requirements-dev.txt\n",[204,2142,2143,2145,2147,2149],{"class":135,"line":389},[204,2144,2092],{"class":220},[204,2146,2135],{"class":2033},[204,2148,1636],{"class":220},[204,2150,2151],{"class":277},"pytest -q\n",[10,2153,2154,2155,2158,2159,2163],{},"Note what is ",[616,2156,2157],{},"not"," there: no Excel, no display, no Windows. openpyxl, pandas and xlsxwriter write and read the file format directly, so a report suite runs on an ordinary Linux runner. The exception is anything driven through ",[17,2160,2162],{"href":2161},"\u002Fgetting-started-with-python-excel-automation\u002Fautomating-excel-with-xlwings-basics\u002F","xlwings",", which needs a real Excel installation and cannot run here — another reason to keep the parts that need the live application as thin and as separate as possible.",[10,2165,2166],{},"Splitting the requirements in two is worth the extra file. The production install gets pandas and openpyxl and nothing else; pytest and its plugins live in the development file, so the environment the scheduled job runs in stays small and the packaged executable does not bundle a test framework.",[23,2168,32,2173,32,2176,32,2179,32,2182,32,2187,32,2189,32,2194,32,2197,32,2201,32,2205,32,2208,32,2213,32,2216,32,2219,32,2222,32,2224,32,2229,32,2232,32,2235,32,2238,32,2243,32,2248,32,2251,32,2254,32,2257,32,2261,32,2264,32,2268,32,2271,32,2275,32,2278,32,2283],{"viewBox":2169,"role":26,"ariaLabelledBy":2170,"xmlns":30,"style":626},"0 0 740 240",[2171,2172],"tp-ci-t","tp-ci-d",[34,2174,2175],{"id":2171},"Where a broken report is caught, and what each catch costs",[38,2177,2178],{"id":2172},"A failure caught by the tests on a developer's machine costs seconds. Caught in continuous integration before merge it costs minutes. Caught by the validation step in the scheduled run it costs a delayed report. Caught by the person who opens the workbook it costs their trust in the numbers.",[42,2180],{"x":44,"y":44,"width":635,"height":2181,"fill":47},"240",[59,2183,2186],{"x":2184,"y":640,"style":2185},"370","font-size:12.5px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","The same defect, caught at four different moments",[42,2188],{"x":50,"y":1568,"width":670,"height":81,"rx":643,"fill":72,"stroke":73,"style":57},[59,2190,2193],{"x":2191,"y":53,"style":2192},"101","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","pytest, locally",[59,2195,2196],{"x":2191,"y":1538,"style":82},"seconds",[59,2198,2200],{"x":2191,"y":2199,"style":67},"136","fix it before",[59,2202,2204],{"x":2191,"y":2203,"style":67},"154","anyone knows",[42,2206],{"x":2207,"y":1568,"width":670,"height":81,"rx":643,"fill":91,"stroke":92,"style":57},"198",[59,2209,2212],{"x":2210,"y":53,"style":2211},"283","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","CI, before merge",[59,2214,2215],{"x":2210,"y":1538,"style":82},"minutes",[59,2217,2218],{"x":2210,"y":2199,"style":67},"the change never",[59,2220,2221],{"x":2210,"y":2203,"style":67},"reaches the server",[42,2223],{"x":169,"y":1568,"width":670,"height":81,"rx":643,"fill":107,"stroke":108,"style":57},[59,2225,2228],{"x":2226,"y":53,"style":2227},"465","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","the 06:00 run",[59,2230,2231],{"x":2226,"y":1538,"style":82},"a late report",[59,2233,2234],{"x":2226,"y":2199,"style":67},"someone is paged,",[59,2236,2237],{"x":2226,"y":2203,"style":67},"nothing is wrong yet",[42,2239],{"x":2240,"y":1568,"width":1549,"height":81,"rx":643,"fill":2241,"stroke":2242,"style":57},"562","#fce9e9","var(--accent-ink,#be185d)",[59,2244,2247],{"x":2245,"y":53,"style":2246},"643","font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","the reader",[59,2249,2250],{"x":2245,"y":1538,"style":82},"a wrong decision",[59,2252,2253],{"x":2245,"y":2199,"style":67},"and every later",[59,2255,2256],{"x":2245,"y":2203,"style":67},"report is doubted",[135,2258],{"x1":2259,"y1":138,"x2":2260,"y2":138,"stroke":140,"style":57},"190","194",[142,2262],{"points":2263,"fill":145},"198,112 188,107 188,117",[135,2265],{"x1":2266,"y1":138,"x2":2267,"y2":138,"stroke":140,"style":57},"372","376",[142,2269],{"points":2270,"fill":145},"380,112 370,107 370,117",[135,2272],{"x1":2273,"y1":138,"x2":2274,"y2":138,"stroke":140,"style":57},"554","558",[142,2276],{"points":2277,"fill":145},"562,112 552,107 552,117",[59,2279,2282],{"x":2184,"y":2280,"style":2281},"204","font-size:11.5px;fill:var(--text,#172033);text-anchor:middle","The cost is not the bug — it is how far it travelled before anything asked whether the output was right.",[59,2284,2286],{"x":2184,"y":2285,"style":67},"226","Which is why the same assertions belong in the suite AND in the job's own pre-delivery check.",[10,2288,2289,2290,2294],{},"The last panel is the one worth designing against. The assertions in the test suite — the sheets exist, the header is right, the row count is plausible, the total reconciles — are equally valid as a check inside the job itself, run against the file it just built and before it is delivered. Writing them once as a function that both the tests and the job call is the cheapest reliability work available in a reporting pipeline, and it is what ",[17,2291,2293],{"href":2292},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it\u002F","Validate an Excel Report Before Sending It"," covers in full.",[183,2296,2298],{"id":2297},"choose-a-handover-format","Choose a handover format",[10,2300,2301],{},"How the script reaches its user determines how much packaging work is worth doing. There are three realistic destinations and they want different things:",[23,2303,32,2308,32,2311,32,2314,32,2317,32,2322,32,2326,32,2330,32,2334,32,2337,32,2341,32,2344,32,2347,32,2350,32,2353,32,2356,32,2359,32,2362,32,2366,32,2369,32,2372,32,2375],{"viewBox":2304,"role":26,"ariaLabelledBy":2305,"xmlns":30,"style":626},"0 0 740 224",[2306,2307],"tp-ship-t","tp-ship-d",[34,2309,2310],{"id":2306},"Three destinations for a report script and what each one needs",[38,2312,2313],{"id":2307},"A server you control needs only a pinned virtual environment and a scheduler entry. A colleague who has Python needs an installable package or a pipx install. A colleague with no Python needs a single packaged executable built with PyInstaller.",[42,2315],{"x":44,"y":44,"width":635,"height":2316,"fill":47},"224",[42,2318],{"x":50,"y":2319,"width":2320,"height":2321,"rx":54,"fill":72,"stroke":73,"style":57},"34","228","156",[59,2323,2325],{"x":698,"y":2324,"style":77},"62","a server you control",[59,2327,2329],{"x":698,"y":2328,"style":2281},"92","pinned virtual environment",[59,2331,2333],{"x":698,"y":2332,"style":2281},"114","absolute interpreter path",[59,2335,2336],{"x":698,"y":2199,"style":2281},"cron or Task Scheduler",[59,2338,2340],{"x":698,"y":2339,"style":67},"168","simplest to update",[42,2342],{"x":2343,"y":2319,"width":2320,"height":2321,"rx":54,"fill":91,"stroke":92,"style":57},"256",[59,2345,2346],{"x":2184,"y":2324,"style":96},"a colleague with Python",[59,2348,2349],{"x":2184,"y":2328,"style":2281},"pyproject.toml package",[59,2351,2352],{"x":2184,"y":2332,"style":2281},"console entry point",[59,2354,2355],{"x":2184,"y":2199,"style":2281},"pipx install from a wheel",[59,2357,2358],{"x":2184,"y":2339,"style":67},"a real command on PATH",[42,2360],{"x":2361,"y":2319,"width":2320,"height":2321,"rx":54,"fill":107,"stroke":108,"style":57},"496",[59,2363,2365],{"x":2364,"y":2324,"style":112},"610","a colleague without Python",[59,2367,2368],{"x":2364,"y":2328,"style":2281},"PyInstaller executable",[59,2370,2371],{"x":2364,"y":2332,"style":2281},"built per operating system",[59,2373,2374],{"x":2364,"y":2199,"style":2281},"config file beside the exe",[59,2376,2377],{"x":2364,"y":2339,"style":67},"largest, hardest to patch",[10,2379,2380,2381,2385],{},"The mistake is reaching for the third option first. A packaged executable is 60 MB, has to be rebuilt on every platform you support, and turns a one-line fix into a rebuild-and-redistribute cycle. It earns its cost only when the user genuinely cannot have Python — which is common on locked-down finance desktops, and rare everywhere else. ",[17,2382,2384],{"href":2383},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller\u002F","Package a Python Excel Script as an EXE with PyInstaller"," covers the hidden-import problems that openpyxl and pandas cause and how to keep the config file outside the bundle.",[10,2387,2388,2389,2392],{},"For the second destination, a minimal ",[201,2390,2391],{},"pyproject.toml"," turns the script into a named command:",[194,2394,2398],{"className":2395,"code":2396,"language":2397,"meta":199,"style":199},"language-toml shiki shiki-themes github-light github-dark-high-contrast","[project]\nname = \"monthly-report\"\nversion = \"1.0.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"pandas>=2.0\", \"openpyxl>=3.1\"]\n\n[project.scripts]\nmonthly-report = \"report:main\"\n\n[build-system]\nrequires = [\"setuptools>=68\"]\nbuild-backend = \"setuptools.build_meta\"\n","toml",[201,2399,2400,2409,2417,2425,2433,2448,2452,2466,2474,2478,2487,2497],{"__ignoreMap":199},[204,2401,2402,2404,2407],{"class":135,"line":206},[204,2403,299],{"class":220},[204,2405,2406],{"class":1928},"project",[204,2408,380],{"class":220},[204,2410,2411,2414],{"class":135,"line":213},[204,2412,2413],{"class":220},"name = ",[204,2415,2416],{"class":277},"\"monthly-report\"\n",[204,2418,2419,2422],{"class":135,"line":230},[204,2420,2421],{"class":220},"version = ",[204,2423,2424],{"class":277},"\"1.0.0\"\n",[204,2426,2427,2430],{"class":135,"line":237},[204,2428,2429],{"class":220},"requires-python = ",[204,2431,2432],{"class":277},"\">=3.9\"\n",[204,2434,2435,2438,2441,2443,2446],{"class":135,"line":251},[204,2436,2437],{"class":220},"dependencies = [",[204,2439,2440],{"class":277},"\"pandas>=2.0\"",[204,2442,305],{"class":220},[204,2444,2445],{"class":277},"\"openpyxl>=3.1\"",[204,2447,380],{"class":220},[204,2449,2450],{"class":135,"line":256},[204,2451,234],{"emptyLinePlaceholder":233},[204,2453,2454,2456,2458,2461,2464],{"class":135,"line":261},[204,2455,299],{"class":220},[204,2457,2406],{"class":1928},[204,2459,2460],{"class":220},".",[204,2462,2463],{"class":1928},"scripts",[204,2465,380],{"class":220},[204,2467,2468,2471],{"class":135,"line":274},[204,2469,2470],{"class":220},"monthly-report = ",[204,2472,2473],{"class":277},"\"report:main\"\n",[204,2475,2476],{"class":135,"line":281},[204,2477,234],{"emptyLinePlaceholder":233},[204,2479,2480,2482,2485],{"class":135,"line":314},[204,2481,299],{"class":220},[204,2483,2484],{"class":1928},"build-system",[204,2486,380],{"class":220},[204,2488,2489,2492,2495],{"class":135,"line":352},[204,2490,2491],{"class":220},"requires = [",[204,2493,2494],{"class":277},"\"setuptools>=68\"",[204,2496,380],{"class":220},[204,2498,2499,2502],{"class":135,"line":383},[204,2500,2501],{"class":220},"build-backend = ",[204,2503,2504],{"class":277},"\"setuptools.build_meta\"\n",[10,2506,2507,2508,2511,2512,2515],{},"After ",[201,2509,2510],{},"pipx install ."," the colleague runs ",[201,2513,2514],{},"monthly-report orders.xlsx -o july.xlsx"," from any directory, in an isolated environment that cannot collide with anything else they have installed.",[183,2517,2519],{"id":2518},"write-down-how-it-is-run","Write down how it is run",[10,2521,2522],{},"The last artefact is not code at all. A report that only one person knows how to run is fragile in a way no test catches, and the fix is a short README beside the script — not a wiki page that will be out of date within a quarter.",[10,2524,2525],{},"Four things earn their place in it: the exact command, including the interpreter path the scheduler uses; where the config file lives and which settings people normally change; what the exit codes mean; and who to contact when the answer is not in the log. Everything else — how the transform works, why a threshold is what it is — belongs in the code or its comments, where it cannot drift away from the thing it describes.",[194,2527,2531],{"className":2528,"code":2530,"language":59,"meta":199},[2529],"language-text","Monthly regional report\n=======================\nRun:      \u002Fopt\u002Freports\u002F.venv\u002Fbin\u002Fpython -m monthly_report build orders.xlsx -o july.xlsx\nConfig:   \u002Fetc\u002Freport\u002Fconfig.prod.toml   (recipients, thresholds, source path)\nSecrets:  REPORT_SMTP_PASSWORD, REPORT_DB_URL — set in the systemd unit\nExit 0:   report written    Exit 1: bad input data    Exit 2: environment failure\nOwner:    finance-systems@example.com\n",[201,2532,2530],{"__ignoreMap":199},[10,2534,2535],{},"Six lines, kept next to the code, updated in the same commit as any change that invalidates them. It is the difference between a colleague being able to run the report while you are on holiday and the report simply not running that month.",[183,2537,2539],{"id":2538},"make-failures-explain-themselves","Make failures explain themselves",[10,2541,2542,2543,2546,2547,2550,2551,2555],{},"The last packaging job is not code that runs — it is what the job says when it stops. A scheduled report that fails with ",[201,2544,2545],{},"KeyError: 'Region'"," in a log nobody reads costs a morning; the same failure reported as ",[616,2548,2549],{},"\"Orders sheet is missing the Region column — got Area, Amount, Date\""," costs a minute. Validate the inputs at the boundary, name the file and the sheet in every message, and let ",[17,2552,2554],{"href":2553},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","error handling and logging"," turn the exit code into an alert someone actually receives.",[10,2557,2558],{},"That is the whole point of the work on this page. Tests catch the failures before the job runs, the command line and config remove the failures caused by editing code under time pressure, and packaging decides who is able to fix the rest.",[183,2560,2562],{"id":2561},"key-takeaways","Key takeaways",[2564,2565,2566,2574,2580,2589,2599,2605,2611],"ul",{},[2567,2568,2569,2573],"li",{},[2570,2571,2572],"strong",{},"Separate the transform from the I\u002FO."," A function that takes a DataFrame and returns a DataFrame can be tested in milliseconds; a function that reads, computes and saves cannot be tested at all.",[2567,2575,2576,2579],{},[2570,2577,2578],{},"Assert on the workbook's shape."," Sheet names, header row, row counts and a total catch the failures that happen. Testing that openpyxl writes cells tests openpyxl.",[2567,2581,2582,2585,2586,2588],{},[2570,2583,2584],{},"Build fixtures in the test."," ",[201,2587,719],{}," plus five lines of pandas beats a checked-in sample file that nobody dares regenerate.",[2567,2590,2591,2594,2595,2598],{},[2570,2592,2593],{},"Give the script a command line."," Arguments instead of code edits, a testable ",[201,2596,2597],{},"parse_args(argv)",", and a real exit code so the scheduler can tell success from failure.",[2567,2600,2601,2604],{},[2570,2602,2603],{},"Layer configuration."," Defaults, then a config file, then environment variables for secrets, then flags — and never let an unset flag overwrite the file.",[2567,2606,2607,2610],{},[2570,2608,2609],{},"Pin the dependencies and point the scheduler at the pinned interpreter."," Unpinned pandas and openpyxl upgrades are a real and recurring cause of overnight report failures.",[2567,2612,2613,2616],{},[2570,2614,2615],{},"Package for the destination."," A pinned environment for your own server, an installable package for a colleague with Python, an executable only when there is no Python at all.",[183,2618,2620],{"id":2619},"frequently-asked-questions","Frequently asked questions",[10,2622,2623,2626],{},[2570,2624,2625],{},"What is actually worth testing in a report script?","\nThe transform and the shape of the output. Assert that the numbers a function returns are right, then assert that the saved workbook has the sheets, headers and row count you expect. Testing that openpyxl can write a cell tests openpyxl, not your report.",[10,2628,2629,2632,2633,2635],{},[2570,2630,2631],{},"Do I need real Excel files as test fixtures?","\nRarely. Build the fixture inside the test with a few lines of openpyxl or pandas and write it to pytest's ",[201,2634,719],{},". A generated fixture is readable, has no licensing or privacy problem, and cannot silently rot when someone edits the checked-in file.",[10,2637,2638,2641],{},[2570,2639,2640],{},"Should the script read its settings from a config file or command-line flags?","\nBoth, layered. Defaults in code, overridden by a config file, overridden by environment variables, overridden by flags. The scheduled run gets a stable file; the person debugging gets a flag they can pass without editing anything.",[10,2643,2644,2647],{},[2570,2645,2646],{},"Is PyInstaller the right way to hand a script to a non-technical colleague?","\nIt is the right answer when they have no Python and must run it on their own desktop. If the script runs on a server you control, a pinned virtual environment is simpler, smaller and far easier to update.",[10,2649,2650,2653],{},[2570,2651,2652],{},"How do I stop a report breaking when a library updates?","\nPin exact versions in a requirements file and install with the same file everywhere. openpyxl and pandas both change behaviour between minor versions — an unpinned scheduled job upgrades itself at the worst moment.",[10,2655,2656,2659],{},[2570,2657,2658],{},"Where should the tests run?","\nOn every change, locally, and ideally in CI before the job is deployed. A report suite is fast — it writes small workbooks to a temporary directory — so there is no reason to run it only when something has already gone wrong.",[183,2661,2663],{"id":2662},"related","Related",[2564,2665,2666,2674,2687,2700,2709],{},[2567,2667,2668,2585,2671,2673],{},[2570,2669,2670],{},"Parent:",[17,2672,20],{"href":19}," — the pipeline these scripts run inside.",[2567,2675,2676,2585,2679,305,2681,305,2683,602,2685,2460],{},[2570,2677,2678],{},"In this topic:",[17,2680,1151],{"href":1150},[17,2682,1494],{"href":1493},[17,2684,1892],{"href":1891},[17,2686,2384],{"href":2383},[2567,2688,2689,2585,2692,2695,2696,2699],{},[2570,2690,2691],{},"Sibling topics:",[17,2693,2694],{"href":2007},"Scheduling Python Excel Scripts with Cron"," runs the packaged job, and ",[17,2697,2698],{"href":2553},"Error Handling and Logging in Excel Automation"," decides what it says when it fails.",[2567,2701,2702,2585,2705,2708],{},[2570,2703,2704],{},"Validation upstream:",[17,2706,2707],{"href":605},"Validating Excel Data with Python"," — the checks a test suite should encode.",[2567,2710,2711,2585,2714,2718],{},[2570,2712,2713],{},"Delivery downstream:",[17,2715,2717],{"href":2716},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","Emailing Excel Reports with smtplib"," — the last step a smoke test should exercise in dry-run mode.",[2720,2721,2722],"style",{},"html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .sx1sP, html code.shiki .sx1sP{--shiki-default:#22863A;--shiki-dark:#72F088}",{"title":199,"searchDepth":213,"depth":213,"links":2724},[2725,2726,2727,2728,2729,2730,2731,2732,2733,2734,2735,2736],{"id":185,"depth":213,"text":186},{"id":610,"depth":213,"text":611},{"id":1155,"depth":213,"text":1156},{"id":1498,"depth":213,"text":1499},{"id":1896,"depth":213,"text":1897},{"id":2012,"depth":213,"text":2013},{"id":2297,"depth":213,"text":2298},{"id":2518,"depth":213,"text":2519},{"id":2538,"depth":213,"text":2539},{"id":2561,"depth":213,"text":2562},{"id":2619,"depth":213,"text":2620},{"id":2662,"depth":213,"text":2663},"2026-08-10","Turn a working report script into something a colleague can run: separate the transform from the I\u002FO, test the workbook you actually wrote, add a command line, move settings into a config file, and ship it.","md",[2741,2743,2745,2747,2749,2751],{"q":2625,"a":2742},"The transform and the shape of the output. Assert that the numbers a function returns are right, then assert that the saved workbook has the sheets, headers and row count you expect. Testing that openpyxl can write a cell tests openpyxl, not your report.",{"q":2631,"a":2744},"Rarely. Build the fixture inside the test with a few lines of openpyxl or pandas and write it to pytest's tmp_path. A generated fixture is readable, has no licensing or privacy problem, and cannot silently rot when someone edits the checked-in file.",{"q":2640,"a":2746},"Both, layered. Defaults in code, overridden by a config file, overridden by environment variables, overridden by flags. The scheduled run gets a stable file; the person debugging gets a flag they can pass without editing anything.",{"q":2646,"a":2748},"It is the right answer when they have no Python and must run it on their own desktop. If the script runs on a server you control, a pinned virtual environment is simpler, smaller and far easier to update.",{"q":2652,"a":2750},"Pin exact versions in a requirements file and install with the same file everywhere. openpyxl and pandas both change behaviour between minor versions — an unpinned scheduled job upgrades itself at the worst moment.",{"q":2658,"a":2752},"On every change, locally, and ideally in CI before the job is deployed. A report suite is fast — it writes small workbooks to a temporary directory — so there is no reason to run it only when something has already gone wrong.",{"breadcrumb":2754},[2755,2757,2758],{"name":2756,"item":429},"Home",{"name":20,"item":19},{"name":5,"item":2759},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002F","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts",{"title":2762,"description":2763},"Test and Package Python Excel Report Scripts","Make an Excel automation script maintainable: pytest against real workbooks, an argparse command line, layered configuration, pinned dependencies and a packaged handover.","testing-and-packaging-excel-automation-scripts","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Findex","guide","Aw1kweiO5-CCErcB92u-XHTgTmMbjC_Lc8dxF3WjjsU",[2769,2773],{"title":2770,"path":2771,"stem":2772,"children":-1},"Schedule Recurring Excel Reports with APScheduler","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler\u002Findex",{"title":1494,"path":2774,"stem":2775,"children":-1},"\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",1786800026511]