[{"data":1,"prerenderedAt":2269},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file":3,"surround:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file":2262},{"id":4,"title":5,"body":6,"dateModified":2231,"datePublished":2231,"description":2232,"extension":2233,"faq":2234,"meta":2243,"navigation":268,"path":2254,"seo":2255,"slug":2258,"stem":2259,"type":2260,"__hash__":2261},"docs\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file\u002Findex.md","Keep Excel Report Settings in a Config File",{"type":7,"value":8,"toc":2218},"minimark",[9,13,22,163,168,197,212,216,219,433,444,448,451,1273,1285,1289,1292,1546,1553,1679,1683,1695,1834,1910,1925,1929,1932,1966,1984,1988,2107,2111,2118,2122,2125,2129,2139,2145,2160,2166,2170,2173,2182,2185,2214],[10,11,12],"p",{},"Every report script accumulates settings: the network path to the source export, the sheet name, the four people who receive it, the threshold that decides what counts as an exception, the mapping from the source system's column names to the ones the finance team recognises. Left in the code, each of them is a reason to edit and redeploy the script for a change that is not a code change at all — and a reason a non-programmer cannot maintain a report they own.",[10,14,15,16,21],{},"Moving them into a file is easy. Doing it so that the scheduled run, the test run and the ad-hoc rerun can differ, without the password ending up in version control, takes a little more structure. This guide is part of ",[17,18,20],"a",{"href":19},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002F","Testing and Packaging Excel Automation Scripts",".",[23,24,32,33,32,37,32,41,32,48,32,58,32,65,32,70,32,74,32,78,32,83,32,87,32,92,32,97,32,100,32,103,32,106,32,109,32,112,32,117,32,122,32,125,32,128,32,131,32,134,32,137,32,143,32,148,32,151,32,154,32,157,32,160],"svg",{"viewBox":25,"role":26,"ariaLabelledBy":27,"xmlns":30,"style":31},"0 0 740 250","img",[28,29],"cfg-split-t","cfg-split-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:740px;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},"Which settings belong in code, in the config file, in the environment and on the command line",[38,39,40],"desc",{"id":29},"Report logic and safe defaults stay in the code. Paths, recipients, thresholds and column mappings belong in a config file that a non-programmer can edit. Passwords and per-machine paths belong in environment variables. Only values that change from run to run — a month, a dry run — belong on the command line.",[42,43],"rect",{"x":44,"y":44,"width":45,"height":46,"fill":47},"0","740","250","#ffffff",[42,49],{"x":50,"y":51,"width":52,"height":53,"rx":54,"fill":55,"stroke":56,"style":57},"16","34","170","188","14","#f0f2f5","var(--line,#cdd5e6)","stroke-width:2px",[59,60,64],"text",{"x":61,"y":62,"style":63},"101","62","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","in the code",[59,66,69],{"x":61,"y":67,"style":68},"92","font-size:11.5px;fill:var(--text,#172033);text-anchor:middle","the transform",[59,71,73],{"x":61,"y":72,"style":68},"114","the workbook layout",[59,75,77],{"x":61,"y":76,"style":68},"136","safe defaults",[59,79,82],{"x":61,"y":80,"style":81},"176","font-size:11px;fill:var(--muted,#5b6780);text-anchor:middle","changing these",[59,84,86],{"x":61,"y":85,"style":81},"194","is a code review",[42,88],{"x":89,"y":51,"width":52,"height":53,"rx":54,"fill":90,"stroke":91,"style":57},"198","#d9f4f1","var(--teal,#0f9488)",[59,93,96],{"x":94,"y":62,"style":95},"283","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","config file",[59,98,99],{"x":94,"y":67,"style":68},"source path, sheet",[59,101,102],{"x":94,"y":72,"style":68},"recipients",[59,104,105],{"x":94,"y":76,"style":68},"thresholds, columns",[59,107,108],{"x":94,"y":80,"style":81},"edited by whoever",[59,110,111],{"x":94,"y":85,"style":81},"owns the report",[42,113],{"x":114,"y":51,"width":52,"height":53,"rx":54,"fill":115,"stroke":116,"style":57},"380","#fdefd8","var(--gold,#b4740a)",[59,118,121],{"x":119,"y":62,"style":120},"465","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","environment",[59,123,124],{"x":119,"y":67,"style":68},"SMTP password",[59,126,127],{"x":119,"y":72,"style":68},"database URL",[59,129,130],{"x":119,"y":76,"style":68},"per-machine paths",[59,132,133],{"x":119,"y":80,"style":81},"never committed,",[59,135,136],{"x":119,"y":85,"style":81},"set by the scheduler",[42,138],{"x":139,"y":51,"width":140,"height":53,"rx":54,"fill":141,"stroke":142,"style":57},"562","162","#ebebfd","var(--brand,#5b5cf0)",[59,144,147],{"x":145,"y":62,"style":146},"643","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","command line",[59,149,150],{"x":145,"y":67,"style":68},"--month 2026-07",[59,152,153],{"x":145,"y":72,"style":68},"--dry-run",[59,155,156],{"x":145,"y":76,"style":68},"--output",[59,158,159],{"x":145,"y":80,"style":81},"true for one run,",[59,161,162],{"x":145,"y":85,"style":81},"not for the next",[164,165,167],"h2",{"id":166},"prerequisites","Prerequisites",[169,170,175],"pre",{"className":171,"code":172,"language":173,"meta":174,"style":174},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[176,177,178],"code",{"__ignoreMap":174},[179,180,183,187,191,194],"span",{"class":181,"line":182},"line",1,[179,184,186],{"class":185},"sMTad","pip",[179,188,190],{"class":189},"srMev"," install",[179,192,193],{"class":189}," pandas",[179,195,196],{"class":189}," openpyxl\n",[10,198,199,200,203,204,207,208,211],{},"On Python 3.11 and newer, ",[176,201,202],{},"tomllib"," reads TOML from the standard library — no install. On 3.9 or 3.10, either ",[176,205,206],{},"pip install tomli"," or use JSON, which ",[176,209,210],{},"json"," handles everywhere. The loader below supports both.",[164,213,215],{"id":214},"step-1-write-the-file-a-non-programmer-can-edit","Step 1: Write the file a non-programmer can edit",[10,217,218],{},"TOML is the better default because it survives a trailing comma, allows comments, and reads like an ini file to someone who has never seen one:",[169,220,224],{"className":221,"code":222,"language":223,"meta":174,"style":174},"language-toml shiki shiki-themes github-light github-dark-high-contrast","# report.toml — settings for the monthly regional report\n[source]\npath  = \"\u002F\u002Ffileserver\u002Fexports\u002Forders.xlsx\"\nsheet = \"Orders\"\n\n[output]\ndirectory = \"reports\"\nfilename  = \"regional-{month}.xlsx\"\n\n[rules]\nmin_amount     = 100.0\nexception_pct  = -5.0        # flag regions down more than 5% year on year\ndrop_test_rows = true\n\n[columns]\n\"Order Ref\"   = \"Order_ID\"\n\"Sales Area\"  = \"Region\"\n\"Net Value\"   = \"Amount\"\n\n[email]\nrecipients = [\"finance@example.com\", \"ops@example.com\"]\nsubject    = \"Regional sales — {month}\"\n# The password is NOT here. Set REPORT_SMTP_PASSWORD in the environment.\n","toml",[176,225,226,232,245,254,263,270,280,289,298,303,313,323,335,344,349,359,368,377,386,391,401,418,427],{"__ignoreMap":174},[179,227,228],{"class":181,"line":182},[179,229,231],{"class":230},"s-wDw","# report.toml — settings for the monthly regional report\n",[179,233,235,239,242],{"class":181,"line":234},2,[179,236,238],{"class":237},"skGVy","[",[179,240,241],{"class":185},"source",[179,243,244],{"class":237},"]\n",[179,246,248,251],{"class":181,"line":247},3,[179,249,250],{"class":237},"path  = ",[179,252,253],{"class":189},"\"\u002F\u002Ffileserver\u002Fexports\u002Forders.xlsx\"\n",[179,255,257,260],{"class":181,"line":256},4,[179,258,259],{"class":237},"sheet = ",[179,261,262],{"class":189},"\"Orders\"\n",[179,264,266],{"class":181,"line":265},5,[179,267,269],{"emptyLinePlaceholder":268},true,"\n",[179,271,273,275,278],{"class":181,"line":272},6,[179,274,238],{"class":237},[179,276,277],{"class":185},"output",[179,279,244],{"class":237},[179,281,283,286],{"class":181,"line":282},7,[179,284,285],{"class":237},"directory = ",[179,287,288],{"class":189},"\"reports\"\n",[179,290,292,295],{"class":181,"line":291},8,[179,293,294],{"class":237},"filename  = ",[179,296,297],{"class":189},"\"regional-{month}.xlsx\"\n",[179,299,301],{"class":181,"line":300},9,[179,302,269],{"emptyLinePlaceholder":268},[179,304,306,308,311],{"class":181,"line":305},10,[179,307,238],{"class":237},[179,309,310],{"class":185},"rules",[179,312,244],{"class":237},[179,314,316,319],{"class":181,"line":315},11,[179,317,318],{"class":237},"min_amount     = ",[179,320,322],{"class":321},"sP0c6","100.0\n",[179,324,326,329,332],{"class":181,"line":325},12,[179,327,328],{"class":237},"exception_pct  = ",[179,330,331],{"class":321},"-5.0",[179,333,334],{"class":230},"        # flag regions down more than 5% year on year\n",[179,336,338,341],{"class":181,"line":337},13,[179,339,340],{"class":237},"drop_test_rows = ",[179,342,343],{"class":321},"true\n",[179,345,347],{"class":181,"line":346},14,[179,348,269],{"emptyLinePlaceholder":268},[179,350,352,354,357],{"class":181,"line":351},15,[179,353,238],{"class":237},[179,355,356],{"class":185},"columns",[179,358,244],{"class":237},[179,360,362,365],{"class":181,"line":361},16,[179,363,364],{"class":237},"\"Order Ref\"   = ",[179,366,367],{"class":189},"\"Order_ID\"\n",[179,369,371,374],{"class":181,"line":370},17,[179,372,373],{"class":237},"\"Sales Area\"  = ",[179,375,376],{"class":189},"\"Region\"\n",[179,378,380,383],{"class":181,"line":379},18,[179,381,382],{"class":237},"\"Net Value\"   = ",[179,384,385],{"class":189},"\"Amount\"\n",[179,387,389],{"class":181,"line":388},19,[179,390,269],{"emptyLinePlaceholder":268},[179,392,394,396,399],{"class":181,"line":393},20,[179,395,238],{"class":237},[179,397,398],{"class":185},"email",[179,400,244],{"class":237},[179,402,404,407,410,413,416],{"class":181,"line":403},21,[179,405,406],{"class":237},"recipients = [",[179,408,409],{"class":189},"\"finance@example.com\"",[179,411,412],{"class":237},", ",[179,414,415],{"class":189},"\"ops@example.com\"",[179,417,244],{"class":237},[179,419,421,424],{"class":181,"line":420},22,[179,422,423],{"class":237},"subject    = ",[179,425,426],{"class":189},"\"Regional sales — {month}\"\n",[179,428,430],{"class":181,"line":429},23,[179,431,432],{"class":230},"# The password is NOT here. Set REPORT_SMTP_PASSWORD in the environment.\n",[10,434,435,436,439,440,443],{},"The ",[176,437,438],{},"[columns]"," block is worth noticing: a mapping from the source system's names to yours is exactly the kind of thing that changes without warning when someone upgrades the export, and exactly the kind of change that should not require a developer. The same goes for the filename template — ",[176,441,442],{},"{month}"," is filled in at runtime, so nobody has to touch code to change how files are named.",[164,445,447],{"id":446},"step-2-load-it-in-layers","Step 2: Load it in layers",[10,449,450],{},"The loader merges four sources in a fixed order, so the more specific always wins:",[169,452,456],{"className":453,"code":454,"language":455,"meta":174,"style":174},"language-python shiki shiki-themes github-light github-dark-high-contrast","# config.py\nimport json\nimport os\nfrom pathlib import Path\n\ntry:\n    import tomllib                      # Python 3.11+\nexcept ModuleNotFoundError:             # 3.9 \u002F 3.10\n    tomllib = None\n\nDEFAULTS = {\n    \"source\": {\"path\": None, \"sheet\": \"Orders\"},\n    \"output\": {\"directory\": \"reports\", \"filename\": \"report-{month}.xlsx\"},\n    \"rules\": {\"min_amount\": 0.0, \"exception_pct\": -5.0, \"drop_test_rows\": True},\n    \"columns\": {},\n    \"email\": {\"recipients\": [], \"subject\": \"Report\", \"password\": None},\n}\n\nENV_MAP = {                             # environment variable -> (section, key)\n    \"REPORT_SOURCE_PATH\": (\"source\", \"path\"),\n    \"REPORT_SMTP_PASSWORD\": (\"email\", \"password\"),\n    \"REPORT_OUTPUT_DIR\": (\"output\", \"directory\"),\n}\n\n\ndef _deep_merge(base, extra):\n    \"\"\"Merge `extra` into a copy of `base`, one level of nesting deep.\"\"\"\n    merged = {k: dict(v) if isinstance(v, dict) else v for k, v in base.items()}\n    for section, value in (extra or {}).items():\n        if isinstance(value, dict) and isinstance(merged.get(section), dict):\n            merged[section].update(value)\n        else:\n            merged[section] = value\n    return merged\n\n\ndef load_config(path=None, env=None, overrides=None):\n    \"\"\"defaults \u003C file \u003C environment \u003C explicit overrides (command-line flags).\"\"\"\n    env = os.environ if env is None else env\n    config = _deep_merge(DEFAULTS, {})\n\n    if path:\n        path = Path(path)\n        if not path.is_file():\n            raise FileNotFoundError(f\"config file not found: {path}\")\n        if path.suffix == \".toml\":\n            if tomllib is None:\n                raise RuntimeError(\"TOML needs Python 3.11+ or `pip install tomli`\")\n            config = _deep_merge(config, tomllib.loads(path.read_text()))\n        else:\n            config = _deep_merge(config, json.loads(path.read_text()))\n\n    for var, (section, key) in ENV_MAP.items():\n        if var in env:\n            config[section][key] = env[var]\n\n    for (section, key), value in (overrides or {}).items():\n        if value is not None:           # an unset flag must not erase the file\n            config[section][key] = value\n    return config\n","python",[176,457,458,463,472,479,492,496,504,515,529,540,544,555,585,618,658,666,698,703,707,720,738,754,770,774,779,784,797,803,852,872,900,906,914,925,934,939,944,975,981,1009,1025,1030,1039,1050,1061,1094,1110,1125,1141,1152,1159,1169,1174,1190,1203,1214,1219,1236,1256,1265],{"__ignoreMap":174},[179,459,460],{"class":181,"line":182},[179,461,462],{"class":230},"# config.py\n",[179,464,465,469],{"class":181,"line":234},[179,466,468],{"class":467},"s-kum","import",[179,470,471],{"class":237}," json\n",[179,473,474,476],{"class":181,"line":247},[179,475,468],{"class":467},[179,477,478],{"class":237}," os\n",[179,480,481,484,487,489],{"class":181,"line":256},[179,482,483],{"class":467},"from",[179,485,486],{"class":237}," pathlib ",[179,488,468],{"class":467},[179,490,491],{"class":237}," Path\n",[179,493,494],{"class":181,"line":265},[179,495,269],{"emptyLinePlaceholder":268},[179,497,498,501],{"class":181,"line":272},[179,499,500],{"class":467},"try",[179,502,503],{"class":237},":\n",[179,505,506,509,512],{"class":181,"line":282},[179,507,508],{"class":467},"    import",[179,510,511],{"class":237}," tomllib                      ",[179,513,514],{"class":230},"# Python 3.11+\n",[179,516,517,520,523,526],{"class":181,"line":291},[179,518,519],{"class":467},"except",[179,521,522],{"class":321}," ModuleNotFoundError",[179,524,525],{"class":237},":             ",[179,527,528],{"class":230},"# 3.9 \u002F 3.10\n",[179,530,531,534,537],{"class":181,"line":300},[179,532,533],{"class":237},"    tomllib ",[179,535,536],{"class":467},"=",[179,538,539],{"class":321}," None\n",[179,541,542],{"class":181,"line":305},[179,543,269],{"emptyLinePlaceholder":268},[179,545,546,549,552],{"class":181,"line":315},[179,547,548],{"class":321},"DEFAULTS",[179,550,551],{"class":467}," =",[179,553,554],{"class":237}," {\n",[179,556,557,560,563,566,569,572,574,577,579,582],{"class":181,"line":325},[179,558,559],{"class":189},"    \"source\"",[179,561,562],{"class":237},": {",[179,564,565],{"class":189},"\"path\"",[179,567,568],{"class":237},": ",[179,570,571],{"class":321},"None",[179,573,412],{"class":237},[179,575,576],{"class":189},"\"sheet\"",[179,578,568],{"class":237},[179,580,581],{"class":189},"\"Orders\"",[179,583,584],{"class":237},"},\n",[179,586,587,590,592,595,597,600,602,605,607,610,613,616],{"class":181,"line":337},[179,588,589],{"class":189},"    \"output\"",[179,591,562],{"class":237},[179,593,594],{"class":189},"\"directory\"",[179,596,568],{"class":237},[179,598,599],{"class":189},"\"reports\"",[179,601,412],{"class":237},[179,603,604],{"class":189},"\"filename\"",[179,606,568],{"class":237},[179,608,609],{"class":189},"\"report-",[179,611,442],{"class":612},"sSjpA",[179,614,615],{"class":189},".xlsx\"",[179,617,584],{"class":237},[179,619,620,623,625,628,630,633,635,638,640,643,646,648,651,653,656],{"class":181,"line":346},[179,621,622],{"class":189},"    \"rules\"",[179,624,562],{"class":237},[179,626,627],{"class":189},"\"min_amount\"",[179,629,568],{"class":237},[179,631,632],{"class":321},"0.0",[179,634,412],{"class":237},[179,636,637],{"class":189},"\"exception_pct\"",[179,639,568],{"class":237},[179,641,642],{"class":467},"-",[179,644,645],{"class":321},"5.0",[179,647,412],{"class":237},[179,649,650],{"class":189},"\"drop_test_rows\"",[179,652,568],{"class":237},[179,654,655],{"class":321},"True",[179,657,584],{"class":237},[179,659,660,663],{"class":181,"line":351},[179,661,662],{"class":189},"    \"columns\"",[179,664,665],{"class":237},": {},\n",[179,667,668,671,673,676,679,682,684,687,689,692,694,696],{"class":181,"line":361},[179,669,670],{"class":189},"    \"email\"",[179,672,562],{"class":237},[179,674,675],{"class":189},"\"recipients\"",[179,677,678],{"class":237},": [], ",[179,680,681],{"class":189},"\"subject\"",[179,683,568],{"class":237},[179,685,686],{"class":189},"\"Report\"",[179,688,412],{"class":237},[179,690,691],{"class":189},"\"password\"",[179,693,568],{"class":237},[179,695,571],{"class":321},[179,697,584],{"class":237},[179,699,700],{"class":181,"line":370},[179,701,702],{"class":237},"}\n",[179,704,705],{"class":181,"line":379},[179,706,269],{"emptyLinePlaceholder":268},[179,708,709,712,714,717],{"class":181,"line":388},[179,710,711],{"class":321},"ENV_MAP",[179,713,551],{"class":467},[179,715,716],{"class":237}," {                             ",[179,718,719],{"class":230},"# environment variable -> (section, key)\n",[179,721,722,725,728,731,733,735],{"class":181,"line":393},[179,723,724],{"class":189},"    \"REPORT_SOURCE_PATH\"",[179,726,727],{"class":237},": (",[179,729,730],{"class":189},"\"source\"",[179,732,412],{"class":237},[179,734,565],{"class":189},[179,736,737],{"class":237},"),\n",[179,739,740,743,745,748,750,752],{"class":181,"line":403},[179,741,742],{"class":189},"    \"REPORT_SMTP_PASSWORD\"",[179,744,727],{"class":237},[179,746,747],{"class":189},"\"email\"",[179,749,412],{"class":237},[179,751,691],{"class":189},[179,753,737],{"class":237},[179,755,756,759,761,764,766,768],{"class":181,"line":420},[179,757,758],{"class":189},"    \"REPORT_OUTPUT_DIR\"",[179,760,727],{"class":237},[179,762,763],{"class":189},"\"output\"",[179,765,412],{"class":237},[179,767,594],{"class":189},[179,769,737],{"class":237},[179,771,772],{"class":181,"line":429},[179,773,702],{"class":237},[179,775,777],{"class":181,"line":776},24,[179,778,269],{"emptyLinePlaceholder":268},[179,780,782],{"class":181,"line":781},25,[179,783,269],{"emptyLinePlaceholder":268},[179,785,787,790,794],{"class":181,"line":786},26,[179,788,789],{"class":467},"def",[179,791,793],{"class":792},"s_Opv"," _deep_merge",[179,795,796],{"class":237},"(base, extra):\n",[179,798,800],{"class":181,"line":799},27,[179,801,802],{"class":189},"    \"\"\"Merge `extra` into a copy of `base`, one level of nesting deep.\"\"\"\n",[179,804,806,809,811,814,817,820,823,826,829,831,834,837,840,843,846,849],{"class":181,"line":805},28,[179,807,808],{"class":237},"    merged ",[179,810,536],{"class":467},[179,812,813],{"class":237}," {k: ",[179,815,816],{"class":321},"dict",[179,818,819],{"class":237},"(v) ",[179,821,822],{"class":467},"if",[179,824,825],{"class":321}," isinstance",[179,827,828],{"class":237},"(v, ",[179,830,816],{"class":321},[179,832,833],{"class":237},") ",[179,835,836],{"class":467},"else",[179,838,839],{"class":237}," v ",[179,841,842],{"class":467},"for",[179,844,845],{"class":237}," k, v ",[179,847,848],{"class":467},"in",[179,850,851],{"class":237}," base.items()}\n",[179,853,855,858,861,863,866,869],{"class":181,"line":854},29,[179,856,857],{"class":467},"    for",[179,859,860],{"class":237}," section, value ",[179,862,848],{"class":467},[179,864,865],{"class":237}," (extra ",[179,867,868],{"class":467},"or",[179,870,871],{"class":237}," {}).items():\n",[179,873,875,878,880,883,885,887,890,892,895,897],{"class":181,"line":874},30,[179,876,877],{"class":467},"        if",[179,879,825],{"class":321},[179,881,882],{"class":237},"(value, ",[179,884,816],{"class":321},[179,886,833],{"class":237},[179,888,889],{"class":467},"and",[179,891,825],{"class":321},[179,893,894],{"class":237},"(merged.get(section), ",[179,896,816],{"class":321},[179,898,899],{"class":237},"):\n",[179,901,903],{"class":181,"line":902},31,[179,904,905],{"class":237},"            merged[section].update(value)\n",[179,907,909,912],{"class":181,"line":908},32,[179,910,911],{"class":467},"        else",[179,913,503],{"class":237},[179,915,917,920,922],{"class":181,"line":916},33,[179,918,919],{"class":237},"            merged[section] ",[179,921,536],{"class":467},[179,923,924],{"class":237}," value\n",[179,926,928,931],{"class":181,"line":927},34,[179,929,930],{"class":467},"    return",[179,932,933],{"class":237}," merged\n",[179,935,937],{"class":181,"line":936},35,[179,938,269],{"emptyLinePlaceholder":268},[179,940,942],{"class":181,"line":941},36,[179,943,269],{"emptyLinePlaceholder":268},[179,945,947,949,952,955,957,959,962,964,966,969,971,973],{"class":181,"line":946},37,[179,948,789],{"class":467},[179,950,951],{"class":792}," load_config",[179,953,954],{"class":237},"(path",[179,956,536],{"class":467},[179,958,571],{"class":321},[179,960,961],{"class":237},", env",[179,963,536],{"class":467},[179,965,571],{"class":321},[179,967,968],{"class":237},", overrides",[179,970,536],{"class":467},[179,972,571],{"class":321},[179,974,899],{"class":237},[179,976,978],{"class":181,"line":977},38,[179,979,980],{"class":189},"    \"\"\"defaults \u003C file \u003C environment \u003C explicit overrides (command-line flags).\"\"\"\n",[179,982,984,987,989,992,994,997,1000,1003,1006],{"class":181,"line":983},39,[179,985,986],{"class":237},"    env ",[179,988,536],{"class":467},[179,990,991],{"class":237}," os.environ ",[179,993,822],{"class":467},[179,995,996],{"class":237}," env ",[179,998,999],{"class":467},"is",[179,1001,1002],{"class":321}," None",[179,1004,1005],{"class":467}," else",[179,1007,1008],{"class":237}," env\n",[179,1010,1012,1015,1017,1020,1022],{"class":181,"line":1011},40,[179,1013,1014],{"class":237},"    config ",[179,1016,536],{"class":467},[179,1018,1019],{"class":237}," _deep_merge(",[179,1021,548],{"class":321},[179,1023,1024],{"class":237},", {})\n",[179,1026,1028],{"class":181,"line":1027},41,[179,1029,269],{"emptyLinePlaceholder":268},[179,1031,1033,1036],{"class":181,"line":1032},42,[179,1034,1035],{"class":467},"    if",[179,1037,1038],{"class":237}," path:\n",[179,1040,1042,1045,1047],{"class":181,"line":1041},43,[179,1043,1044],{"class":237},"        path ",[179,1046,536],{"class":467},[179,1048,1049],{"class":237}," Path(path)\n",[179,1051,1053,1055,1058],{"class":181,"line":1052},44,[179,1054,877],{"class":467},[179,1056,1057],{"class":467}," not",[179,1059,1060],{"class":237}," path.is_file():\n",[179,1062,1064,1067,1070,1073,1076,1079,1082,1085,1088,1091],{"class":181,"line":1063},45,[179,1065,1066],{"class":467},"            raise",[179,1068,1069],{"class":321}," FileNotFoundError",[179,1071,1072],{"class":237},"(",[179,1074,1075],{"class":467},"f",[179,1077,1078],{"class":189},"\"config file not found: ",[179,1080,1081],{"class":612},"{",[179,1083,1084],{"class":237},"path",[179,1086,1087],{"class":612},"}",[179,1089,1090],{"class":189},"\"",[179,1092,1093],{"class":237},")\n",[179,1095,1097,1099,1102,1105,1108],{"class":181,"line":1096},46,[179,1098,877],{"class":467},[179,1100,1101],{"class":237}," path.suffix ",[179,1103,1104],{"class":467},"==",[179,1106,1107],{"class":189}," \".toml\"",[179,1109,503],{"class":237},[179,1111,1113,1116,1119,1121,1123],{"class":181,"line":1112},47,[179,1114,1115],{"class":467},"            if",[179,1117,1118],{"class":237}," tomllib ",[179,1120,999],{"class":467},[179,1122,1002],{"class":321},[179,1124,503],{"class":237},[179,1126,1128,1131,1134,1136,1139],{"class":181,"line":1127},48,[179,1129,1130],{"class":467},"                raise",[179,1132,1133],{"class":321}," RuntimeError",[179,1135,1072],{"class":237},[179,1137,1138],{"class":189},"\"TOML needs Python 3.11+ or `pip install tomli`\"",[179,1140,1093],{"class":237},[179,1142,1144,1147,1149],{"class":181,"line":1143},49,[179,1145,1146],{"class":237},"            config ",[179,1148,536],{"class":467},[179,1150,1151],{"class":237}," _deep_merge(config, tomllib.loads(path.read_text()))\n",[179,1153,1155,1157],{"class":181,"line":1154},50,[179,1156,911],{"class":467},[179,1158,503],{"class":237},[179,1160,1162,1164,1166],{"class":181,"line":1161},51,[179,1163,1146],{"class":237},[179,1165,536],{"class":467},[179,1167,1168],{"class":237}," _deep_merge(config, json.loads(path.read_text()))\n",[179,1170,1172],{"class":181,"line":1171},52,[179,1173,269],{"emptyLinePlaceholder":268},[179,1175,1177,1179,1182,1184,1187],{"class":181,"line":1176},53,[179,1178,857],{"class":467},[179,1180,1181],{"class":237}," var, (section, key) ",[179,1183,848],{"class":467},[179,1185,1186],{"class":321}," ENV_MAP",[179,1188,1189],{"class":237},".items():\n",[179,1191,1193,1195,1198,1200],{"class":181,"line":1192},54,[179,1194,877],{"class":467},[179,1196,1197],{"class":237}," var ",[179,1199,848],{"class":467},[179,1201,1202],{"class":237}," env:\n",[179,1204,1206,1209,1211],{"class":181,"line":1205},55,[179,1207,1208],{"class":237},"            config[section][key] ",[179,1210,536],{"class":467},[179,1212,1213],{"class":237}," env[var]\n",[179,1215,1217],{"class":181,"line":1216},56,[179,1218,269],{"emptyLinePlaceholder":268},[179,1220,1222,1224,1227,1229,1232,1234],{"class":181,"line":1221},57,[179,1223,857],{"class":467},[179,1225,1226],{"class":237}," (section, key), value ",[179,1228,848],{"class":467},[179,1230,1231],{"class":237}," (overrides ",[179,1233,868],{"class":467},[179,1235,871],{"class":237},[179,1237,1239,1241,1244,1246,1248,1250,1253],{"class":181,"line":1238},58,[179,1240,877],{"class":467},[179,1242,1243],{"class":237}," value ",[179,1245,999],{"class":467},[179,1247,1057],{"class":467},[179,1249,1002],{"class":321},[179,1251,1252],{"class":237},":           ",[179,1254,1255],{"class":230},"# an unset flag must not erase the file\n",[179,1257,1259,1261,1263],{"class":181,"line":1258},59,[179,1260,1208],{"class":237},[179,1262,536],{"class":467},[179,1264,924],{"class":237},[179,1266,1268,1270],{"class":181,"line":1267},60,[179,1269,930],{"class":467},[179,1271,1272],{"class":237}," config\n",[10,1274,1275,1276,1279,1280,1282,1283,21],{},"Two rules do most of the work. The environment layer sits above the file so a machine can override a path without editing a shared file, and secrets can arrive without ever being written down. The ",[176,1277,1278],{},"if value is not None"," guard stops argparse's unset defaults from wiping settings — without it, running the tool with no ",[176,1281,156],{}," flag silently resets the output directory to ",[176,1284,571],{},[164,1286,1288],{"id":1287},"step-3-validate-at-startup-not-at-the-save","Step 3: Validate at startup, not at the save",[10,1290,1291],{},"A config mistake that surfaces forty seconds into a run, after the source has been read and the transform has finished, wastes the run and buries the cause. Check everything the moment the file is loaded:",[169,1293,1295],{"className":453,"code":1294,"language":455,"meta":174,"style":174},"def validate(config):\n    problems = []\n\n    source = config[\"source\"][\"path\"]\n    if not source:\n        problems.append(\"source.path is not set\")\n    elif not Path(source).exists():\n        problems.append(f\"source.path does not exist: {source}\")\n\n    if not config[\"email\"][\"recipients\"]:\n        problems.append(\"email.recipients is empty — nobody would receive the report\")\n\n    if not isinstance(config[\"rules\"][\"min_amount\"], (int, float)):\n        problems.append(\"rules.min_amount must be a number\")\n\n    if config[\"email\"][\"password\"] is None:\n        problems.append(\"REPORT_SMTP_PASSWORD is not set in the environment\")\n\n    if problems:\n        raise SystemExit(\"Configuration problems:\\n  - \" + \"\\n  - \".join(problems))\n    return config\n",[176,1296,1297,1307,1317,1321,1340,1349,1359,1369,1388,1392,1409,1418,1422,1454,1463,1467,1488,1497,1501,1508,1540],{"__ignoreMap":174},[179,1298,1299,1301,1304],{"class":181,"line":182},[179,1300,789],{"class":467},[179,1302,1303],{"class":792}," validate",[179,1305,1306],{"class":237},"(config):\n",[179,1308,1309,1312,1314],{"class":181,"line":234},[179,1310,1311],{"class":237},"    problems ",[179,1313,536],{"class":467},[179,1315,1316],{"class":237}," []\n",[179,1318,1319],{"class":181,"line":247},[179,1320,269],{"emptyLinePlaceholder":268},[179,1322,1323,1326,1328,1331,1333,1336,1338],{"class":181,"line":256},[179,1324,1325],{"class":237},"    source ",[179,1327,536],{"class":467},[179,1329,1330],{"class":237}," config[",[179,1332,730],{"class":189},[179,1334,1335],{"class":237},"][",[179,1337,565],{"class":189},[179,1339,244],{"class":237},[179,1341,1342,1344,1346],{"class":181,"line":265},[179,1343,1035],{"class":467},[179,1345,1057],{"class":467},[179,1347,1348],{"class":237}," source:\n",[179,1350,1351,1354,1357],{"class":181,"line":272},[179,1352,1353],{"class":237},"        problems.append(",[179,1355,1356],{"class":189},"\"source.path is not set\"",[179,1358,1093],{"class":237},[179,1360,1361,1364,1366],{"class":181,"line":282},[179,1362,1363],{"class":467},"    elif",[179,1365,1057],{"class":467},[179,1367,1368],{"class":237}," Path(source).exists():\n",[179,1370,1371,1373,1375,1378,1380,1382,1384,1386],{"class":181,"line":291},[179,1372,1353],{"class":237},[179,1374,1075],{"class":467},[179,1376,1377],{"class":189},"\"source.path does not exist: ",[179,1379,1081],{"class":612},[179,1381,241],{"class":237},[179,1383,1087],{"class":612},[179,1385,1090],{"class":189},[179,1387,1093],{"class":237},[179,1389,1390],{"class":181,"line":300},[179,1391,269],{"emptyLinePlaceholder":268},[179,1393,1394,1396,1398,1400,1402,1404,1406],{"class":181,"line":305},[179,1395,1035],{"class":467},[179,1397,1057],{"class":467},[179,1399,1330],{"class":237},[179,1401,747],{"class":189},[179,1403,1335],{"class":237},[179,1405,675],{"class":189},[179,1407,1408],{"class":237},"]:\n",[179,1410,1411,1413,1416],{"class":181,"line":315},[179,1412,1353],{"class":237},[179,1414,1415],{"class":189},"\"email.recipients is empty — nobody would receive the report\"",[179,1417,1093],{"class":237},[179,1419,1420],{"class":181,"line":325},[179,1421,269],{"emptyLinePlaceholder":268},[179,1423,1424,1426,1428,1430,1433,1436,1438,1440,1443,1446,1448,1451],{"class":181,"line":337},[179,1425,1035],{"class":467},[179,1427,1057],{"class":467},[179,1429,825],{"class":321},[179,1431,1432],{"class":237},"(config[",[179,1434,1435],{"class":189},"\"rules\"",[179,1437,1335],{"class":237},[179,1439,627],{"class":189},[179,1441,1442],{"class":237},"], (",[179,1444,1445],{"class":321},"int",[179,1447,412],{"class":237},[179,1449,1450],{"class":321},"float",[179,1452,1453],{"class":237},")):\n",[179,1455,1456,1458,1461],{"class":181,"line":346},[179,1457,1353],{"class":237},[179,1459,1460],{"class":189},"\"rules.min_amount must be a number\"",[179,1462,1093],{"class":237},[179,1464,1465],{"class":181,"line":351},[179,1466,269],{"emptyLinePlaceholder":268},[179,1468,1469,1471,1473,1475,1477,1479,1482,1484,1486],{"class":181,"line":361},[179,1470,1035],{"class":467},[179,1472,1330],{"class":237},[179,1474,747],{"class":189},[179,1476,1335],{"class":237},[179,1478,691],{"class":189},[179,1480,1481],{"class":237},"] ",[179,1483,999],{"class":467},[179,1485,1002],{"class":321},[179,1487,503],{"class":237},[179,1489,1490,1492,1495],{"class":181,"line":370},[179,1491,1353],{"class":237},[179,1493,1494],{"class":189},"\"REPORT_SMTP_PASSWORD is not set in the environment\"",[179,1496,1093],{"class":237},[179,1498,1499],{"class":181,"line":379},[179,1500,269],{"emptyLinePlaceholder":268},[179,1502,1503,1505],{"class":181,"line":388},[179,1504,1035],{"class":467},[179,1506,1507],{"class":237}," problems:\n",[179,1509,1510,1513,1516,1518,1521,1524,1527,1530,1533,1535,1537],{"class":181,"line":393},[179,1511,1512],{"class":467},"        raise",[179,1514,1515],{"class":321}," SystemExit",[179,1517,1072],{"class":237},[179,1519,1520],{"class":189},"\"Configuration problems:",[179,1522,1523],{"class":612},"\\n",[179,1525,1526],{"class":189},"  - \"",[179,1528,1529],{"class":467}," +",[179,1531,1532],{"class":189}," \"",[179,1534,1523],{"class":612},[179,1536,1526],{"class":189},[179,1538,1539],{"class":237},".join(problems))\n",[179,1541,1542,1544],{"class":181,"line":403},[179,1543,930],{"class":467},[179,1545,1272],{"class":237},[10,1547,1548,1549,1552],{},"Collecting every problem before raising is deliberate: a loader that stops at the first mistake makes someone fix a typo, rerun, and discover the next one. One message listing all four is one round trip. Raising ",[176,1550,1551],{},"SystemExit"," with a string prints the message and exits non-zero without a traceback, which is what a scheduled job's log should contain.",[23,1554,32,1559,32,1562,32,1565,32,1568,32,1574,32,1580,32,1586,32,1590,32,1594,32,1596,32,1600,32,1605,32,1610,32,1614,32,1618,32,1622,32,1626,32,1630,32,1635,32,1638,32,1641,32,1645,32,1648,32,1651,32,1655,32,1658,32,1661,32,1664,32,1670,32,1675],{"viewBox":1555,"role":26,"ariaLabelledBy":1556,"xmlns":30,"style":31},"0 0 740 244",[1557,1558],"cfg-fail-t","cfg-fail-d",[34,1560,1561],{"id":1557},"Validating at startup versus discovering the problem mid-run",[38,1563,1564],{"id":1558},"Without startup validation the job reads the source, transforms the data and only fails at the delivery step, forty seconds in, with an error that names a missing recipient rather than the setting behind it. With validation the same problem is reported in under a second, listing every issue at once, before any work is done.",[42,1566],{"x":44,"y":44,"width":45,"height":1567,"fill":47},"244",[59,1569,1573],{"x":1570,"y":1571,"style":1572},"184","32","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","no startup validation",[42,1575],{"x":50,"y":1576,"width":1577,"height":1578,"rx":1579,"fill":55,"stroke":56},"48","76","40","8",[59,1581,1585],{"x":1582,"y":1583,"style":1584},"54","73","font-size:11px;fill:var(--text,#172033);text-anchor:middle","read",[42,1587],{"x":1588,"y":1576,"width":1589,"height":1578,"rx":1579,"fill":55,"stroke":56},"100","86",[59,1591,1593],{"x":1592,"y":1583,"style":1584},"143","transform",[42,1595],{"x":85,"y":1576,"width":1577,"height":1578,"rx":1579,"fill":55,"stroke":56},[59,1597,1599],{"x":1598,"y":1583,"style":1584},"232","write",[42,1601],{"x":1602,"y":1576,"width":1577,"height":1578,"rx":1579,"fill":1603,"stroke":1604,"style":57},"278","#fce9e9","var(--accent-ink,#be185d)",[59,1606,1609],{"x":1607,"y":1583,"style":1608},"316","font-size:11px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","send ✗",[59,1611,1613],{"x":1570,"y":1612,"style":68},"110","fails after 40 seconds of work",[59,1615,1617],{"x":1570,"y":1616,"style":81},"132","the message names an empty recipient list,",[59,1619,1621],{"x":1570,"y":1620,"style":81},"150","not the config key that caused it",[59,1623,1625],{"x":1624,"y":1571,"style":95},"556","validate on load",[42,1627],{"x":1628,"y":1576,"width":1629,"height":1578,"rx":1579,"fill":90,"stroke":91,"style":57},"388","104",[59,1631,1634],{"x":1632,"y":1583,"style":1633},"440","font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","load + check",[42,1636],{"x":1637,"y":1576,"width":1577,"height":1578,"rx":1579,"fill":55,"stroke":56},"500",[59,1639,1585],{"x":1640,"y":1583,"style":81},"538",[42,1642],{"x":1643,"y":1576,"width":1644,"height":1578,"rx":1579,"fill":55,"stroke":56},"584","66",[59,1646,1599],{"x":1647,"y":1583,"style":81},"617",[42,1649],{"x":1650,"y":1576,"width":1644,"height":1578,"rx":1579,"fill":55,"stroke":56},"658",[59,1652,1654],{"x":1653,"y":1583,"style":81},"691","send",[59,1656,1657],{"x":1624,"y":1612,"style":68},"stops in under a second",[59,1659,1660],{"x":1624,"y":1616,"style":81},"and lists every problem at once, by key,",[59,1662,1663],{"x":1624,"y":1620,"style":81},"so one edit fixes all of them",[42,1665],{"x":1666,"y":52,"width":1637,"height":1667,"rx":1668,"fill":1669,"stroke":142},"120","56","12","#f0f4ff",[59,1671,1674],{"x":1672,"y":85,"style":1673},"370","font-size:11.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Rule: a setting is checked where it is loaded, not where it is used",[59,1676,1678],{"x":1672,"y":1677,"style":81},"215","the cost of the check is the same; the cost of the failure is not",[164,1680,1682],{"id":1681},"step-4-wire-it-to-the-command-line","Step 4: Wire it to the command line",[10,1684,1685,1686,1690,1691,1694],{},"The config file and the ",[17,1687,1689],{"href":1688},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse\u002F","argparse layer"," meet in three lines of ",[176,1692,1693],{},"main",":",[169,1696,1698],{"className":453,"code":1697,"language":455,"meta":174,"style":174},"def main(argv=None):\n    args = parse_args(argv)\n    config = validate(load_config(\n        path=args.config or os.environ.get(\"REPORT_CONFIG\", \"report.toml\"),\n        overrides={\n            (\"source\", \"path\"): args.source,        # None unless the flag was given\n            (\"output\", \"directory\"): args.output_dir,\n        },\n    ))\n    return run(config, month=args.month, dry_run=args.dry_run)\n",[176,1699,1700,1716,1726,1735,1761,1771,1788,1801,1806,1811],{"__ignoreMap":174},[179,1701,1702,1704,1707,1710,1712,1714],{"class":181,"line":182},[179,1703,789],{"class":467},[179,1705,1706],{"class":792}," main",[179,1708,1709],{"class":237},"(argv",[179,1711,536],{"class":467},[179,1713,571],{"class":321},[179,1715,899],{"class":237},[179,1717,1718,1721,1723],{"class":181,"line":234},[179,1719,1720],{"class":237},"    args ",[179,1722,536],{"class":467},[179,1724,1725],{"class":237}," parse_args(argv)\n",[179,1727,1728,1730,1732],{"class":181,"line":247},[179,1729,1014],{"class":237},[179,1731,536],{"class":467},[179,1733,1734],{"class":237}," validate(load_config(\n",[179,1736,1737,1741,1743,1746,1748,1751,1754,1756,1759],{"class":181,"line":256},[179,1738,1740],{"class":1739},"sa561","        path",[179,1742,536],{"class":467},[179,1744,1745],{"class":237},"args.config ",[179,1747,868],{"class":467},[179,1749,1750],{"class":237}," os.environ.get(",[179,1752,1753],{"class":189},"\"REPORT_CONFIG\"",[179,1755,412],{"class":237},[179,1757,1758],{"class":189},"\"report.toml\"",[179,1760,737],{"class":237},[179,1762,1763,1766,1768],{"class":181,"line":265},[179,1764,1765],{"class":1739},"        overrides",[179,1767,536],{"class":467},[179,1769,1770],{"class":237},"{\n",[179,1772,1773,1776,1778,1780,1782,1785],{"class":181,"line":272},[179,1774,1775],{"class":237},"            (",[179,1777,730],{"class":189},[179,1779,412],{"class":237},[179,1781,565],{"class":189},[179,1783,1784],{"class":237},"): args.source,        ",[179,1786,1787],{"class":230},"# None unless the flag was given\n",[179,1789,1790,1792,1794,1796,1798],{"class":181,"line":282},[179,1791,1775],{"class":237},[179,1793,763],{"class":189},[179,1795,412],{"class":237},[179,1797,594],{"class":189},[179,1799,1800],{"class":237},"): args.output_dir,\n",[179,1802,1803],{"class":181,"line":291},[179,1804,1805],{"class":237},"        },\n",[179,1807,1808],{"class":181,"line":300},[179,1809,1810],{"class":237},"    ))\n",[179,1812,1813,1815,1818,1821,1823,1826,1829,1831],{"class":181,"line":305},[179,1814,930],{"class":467},[179,1816,1817],{"class":237}," run(config, ",[179,1819,1820],{"class":1739},"month",[179,1822,536],{"class":467},[179,1824,1825],{"class":237},"args.month, ",[179,1827,1828],{"class":1739},"dry_run",[179,1830,536],{"class":467},[179,1832,1833],{"class":237},"args.dry_run)\n",[23,1835,32,1840,32,1843,32,1846,32,1849,32,1854,32,1859,32,1865,32,1869,32,1873,32,1877,32,1880,32,1884,32,1887,32,1890,32,1893,32,1896,32,1901,32,1904,32,1907],{"viewBox":1836,"role":26,"ariaLabelledBy":1837,"xmlns":30,"style":31},"0 0 740 214",[1838,1839],"cfg-env-t","cfg-env-d",[34,1841,1842],{"id":1838},"Choosing the config file for each environment",[38,1844,1845],{"id":1839},"The scheduled production job sets REPORT_CONFIG once in its own environment and never passes a flag. A developer passes --config with the development file, which wins over the variable. Neither has to edit a shared file, so the two environments cannot cross over.",[42,1847],{"x":44,"y":44,"width":45,"height":1848,"fill":47},"214",[59,1850,1853],{"x":1672,"y":1851,"style":1852},"30","font-size:12.5px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","One binary, two environments, no shared file to edit",[42,1855],{"x":50,"y":1856,"width":1857,"height":1858,"rx":54,"fill":141,"stroke":142,"style":57},"50","228","140",[59,1860,1864],{"x":1861,"y":1862,"style":1863},"130","78","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","the scheduled job",[59,1866,1868],{"x":1861,"y":1867,"style":1584},"106","REPORT_CONFIG set once",[59,1870,1872],{"x":1861,"y":1871,"style":1584},"128","in the unit or crontab",[59,1874,1876],{"x":1861,"y":1875,"style":81},"158","config.prod.toml",[42,1878],{"x":1879,"y":1856,"width":1857,"height":1858,"rx":54,"fill":90,"stroke":91,"style":57},"256",[59,1881,1883],{"x":1672,"y":1862,"style":1882},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","a developer's run",[59,1885,1886],{"x":1672,"y":1867,"style":1584},"--config passed on the",[59,1888,1889],{"x":1672,"y":1871,"style":1584},"command line, one run",[59,1891,1892],{"x":1672,"y":1875,"style":81},"config.dev.toml",[42,1894],{"x":1895,"y":1856,"width":1857,"height":1858,"rx":54,"fill":55,"stroke":56,"style":57},"496",[59,1897,1900],{"x":1898,"y":1862,"style":1899},"610","font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","neither one",[59,1902,1903],{"x":1898,"y":1867,"style":1584},"falls back to report.toml",[59,1905,1906],{"x":1898,"y":1871,"style":1584},"beside the script",[59,1908,1909],{"x":1898,"y":1875,"style":81},"so a bare run still works",[10,1911,1912,1913,1916,1917,1920,1921,1924],{},"Selecting the file itself through a flag ",[1914,1915,889],"em",{}," an environment variable is what makes multiple environments workable: the scheduled production job sets ",[176,1918,1919],{},"REPORT_CONFIG=\u002Fetc\u002Freport\u002Fconfig.prod.toml"," once, and a developer runs ",[176,1922,1923],{},"--config config.dev.toml"," without changing anything that persists.",[164,1926,1928],{"id":1927},"step-5-keep-the-secrets-out-of-the-repository","Step 5: Keep the secrets out of the repository",[10,1930,1931],{},"Commit an example, never the real thing:",[169,1933,1935],{"className":171,"code":1934,"language":173,"meta":174,"style":174},"# .gitignore\nreport.toml\n.env\n\n# committed instead\nreport.example.toml\n",[176,1936,1937,1942,1947,1952,1956,1961],{"__ignoreMap":174},[179,1938,1939],{"class":181,"line":182},[179,1940,1941],{"class":230},"# .gitignore\n",[179,1943,1944],{"class":181,"line":234},[179,1945,1946],{"class":185},"report.toml\n",[179,1948,1949],{"class":181,"line":247},[179,1950,1951],{"class":185},".env\n",[179,1953,1954],{"class":181,"line":256},[179,1955,269],{"emptyLinePlaceholder":268},[179,1957,1958],{"class":181,"line":265},[179,1959,1960],{"class":230},"# committed instead\n",[179,1962,1963],{"class":181,"line":272},[179,1964,1965],{"class":185},"report.example.toml\n",[10,1967,1968,1971,1972,1975,1976,1979,1980,1983],{},[176,1969,1970],{},"report.example.toml"," carries every key with a placeholder value, so a new machine is set up by copying and editing rather than by guessing. For local development a ",[176,1973,1974],{},".env"," file loaded by your shell is fine; on a server, set the variables in the scheduler's environment — cron reads ",[176,1977,1978],{},"\u002Fetc\u002Fenvironment"," and a systemd unit takes ",[176,1981,1982],{},"Environment="," lines, both of which keep the secret out of any file the report code can accidentally print.",[164,1985,1987],{"id":1986},"common-pitfalls-and-gotchas","Common pitfalls and gotchas",[1989,1990,1991,2007],"table",{},[1992,1993,1994],"thead",{},[1995,1996,1997,2001,2004],"tr",{},[1998,1999,2000],"th",{},"Symptom",[1998,2002,2003],{},"Cause",[1998,2005,2006],{},"Fix",[2008,2009,2010,2024,2039,2050,2065,2079,2096],"tbody",{},[1995,2011,2012,2016,2019],{},[2013,2014,2015],"td",{},"A flag has no effect",[2013,2017,2018],{},"Merge overwrote it with the file, or the guard is missing",[2013,2020,2021,2022],{},"Apply overrides last, skip ",[176,2023,571],{},[1995,2025,2026,2029,2032],{},[2013,2027,2028],{},"Works locally, fails under cron",[2013,2030,2031],{},"Relative config path resolved against a different working directory",[2013,2033,2034,2035,2038],{},"Resolve with ",[176,2036,2037],{},"Path(__file__).parent"," or pass an absolute path",[1995,2040,2041,2044,2047],{},[2013,2042,2043],{},"Password appears in a log",[2013,2045,2046],{},"Config dict logged wholesale",[2013,2048,2049],{},"Redact known secret keys before logging",[1995,2051,2052,2057,2060],{},[2013,2053,2054,2056],{},[176,2055,202],{}," import error",[2013,2058,2059],{},"Python 3.10 or older",[2013,2061,2062,2064],{},[176,2063,206],{},", or use JSON",[1995,2066,2067,2070,2076],{},[2013,2068,2069],{},"Recipients arrive as one string",[2013,2071,2072,2073],{},"JSON edited to ",[176,2074,2075],{},"\"a@x.com, b@x.com\"",[2013,2077,2078],{},"Keep it a list; validate the type",[1995,2080,2081,2084,2090],{},[2013,2082,2083],{},"Numbers read as text",[2013,2085,2086,2087],{},"Quoted in the file: ",[176,2088,2089],{},"min_amount = \"100\"",[2013,2091,2092,2093],{},"Drop the quotes; validate with ",[176,2094,2095],{},"isinstance",[1995,2097,2098,2101,2104],{},[2013,2099,2100],{},"Two environments cross over",[2013,2102,2103],{},"One file with an internal switch",[2013,2105,2106],{},"One file per environment, selected explicitly",[164,2108,2110],{"id":2109},"performance-and-scale-notes","Performance and scale notes",[10,2112,2113,2114,2117],{},"Loading and validating a config file costs a millisecond, so the only scale question is how many reports share one. Past three or four jobs, prefer one file per job over a large file with a section per report: a shared file makes every change a change to every job, and the blast radius of a typo grows with the number of readers. Where several jobs genuinely share settings — an SMTP host, a shared export directory — put those in a small common file and merge it underneath the per-job one, using the same ",[176,2115,2116],{},"_deep_merge"," in one more layer.",[164,2119,2121],{"id":2120},"conclusion","Conclusion",[10,2123,2124],{},"Settings that change without the logic changing do not belong in the code. Put them in a TOML file the report's owner can edit, layer environment variables above it for secrets and per-machine paths, and let command-line flags win for the values that differ run to run. Validate the whole thing at startup and report every problem at once, commit an example rather than the real file, and the report becomes something that can be maintained by whoever owns it rather than only by whoever wrote it.",[164,2126,2128],{"id":2127},"frequently-asked-questions","Frequently asked questions",[10,2130,2131,2135,2136,2138],{},[2132,2133,2134],"strong",{},"TOML, JSON or YAML for the config file?","\nTOML, if you are on Python 3.11 or newer — ",[176,2137,202],{}," is in the standard library and the format tolerates comments and trailing commas. JSON is the fallback with no dependency on older versions; YAML needs PyYAML and its whitespace rules trip up the non-programmers who most often edit these files.",[10,2140,2141,2144],{},[2132,2142,2143],{},"Where should the SMTP password live?","\nIn an environment variable or a secret store, never in the file. A config file gets committed, copied into a ticket and attached to an email; a variable set by the scheduler does not.",[10,2146,2147,2150,2151,412,2153,2155,2156,2159],{},[2132,2148,2149],{},"How do I keep separate settings for test and production?","\nOne file per environment — ",[176,2152,1892],{},[176,2154,1876],{}," — selected by a ",[176,2157,2158],{},"--config"," flag or an environment variable. Avoid a single file with an \"environment\" switch inside it; the wrong branch is too easy to hit.",[10,2161,2162,2165],{},[2132,2163,2164],{},"Should the config file live next to the script or in the user's home directory?","\nNext to the script for a scheduled job, so the settings travel with the deployment. Use a home-directory path only for a tool people install and personalise.",[164,2167,2169],{"id":2168},"related","Related",[10,2171,2172],{},"Up to the parent guide:",[2174,2175,2176],"ul",{},[2177,2178,2179,2181],"li",{},[17,2180,20],{"href":19}," — how configuration sits between the command line and packaging.",[10,2183,2184],{},"Related guides:",[2174,2186,2187,2193,2200,2207],{},[2177,2188,2189,2192],{},[17,2190,2191],{"href":1688},"Build a Command-Line Tool for Excel Reports with argparse"," — the layer that overrides this one.",[2177,2194,2195,2199],{},[17,2196,2198],{"href":2197},"\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"," — keeping the config file outside the bundle so it stays editable.",[2177,2201,2202,2206],{},[17,2203,2205],{"href":2204},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python\u002F","Send an Excel Report to Multiple Recipients in Python"," — the recipient list this file holds.",[2177,2208,2209,2213],{},[17,2210,2212],{"href":2211},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler\u002F","Schedule Recurring Excel Reports with APScheduler"," — where the environment variables are set for an unattended run.",[2215,2216,2217],"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-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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 .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}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}",{"title":174,"searchDepth":234,"depth":234,"links":2219},[2220,2221,2222,2223,2224,2225,2226,2227,2228,2229,2230],{"id":166,"depth":234,"text":167},{"id":214,"depth":234,"text":215},{"id":446,"depth":234,"text":447},{"id":1287,"depth":234,"text":1288},{"id":1681,"depth":234,"text":1682},{"id":1927,"depth":234,"text":1928},{"id":1986,"depth":234,"text":1987},{"id":2109,"depth":234,"text":2110},{"id":2120,"depth":234,"text":2121},{"id":2127,"depth":234,"text":2128},{"id":2168,"depth":234,"text":2169},"2026-08-10","Move paths, recipients, thresholds and column mappings out of the code: a layered loader with defaults, a TOML or JSON file, environment variables for secrets, and validation that fails at startup.","md",[2235,2237,2239,2241],{"q":2134,"a":2236},"TOML, if you are on Python 3.11 or newer — tomllib is in the standard library and the format tolerates comments and trailing commas. JSON is the fallback with no dependency on older versions; YAML needs PyYAML and its whitespace rules trip up the non-programmers who most often edit these files.",{"q":2143,"a":2238},"In an environment variable or a secret store, never in the file. A config file gets committed, copied into a ticket and attached to an email; a variable set by the scheduler does not.",{"q":2149,"a":2240},"One file per environment — config.dev.toml, config.prod.toml — selected by a --config flag or an environment variable. Avoid a single file with an \"environment\" switch inside it; the wrong branch is too easy to hit.",{"q":2164,"a":2242},"Next to the script for a scheduled job, so the settings travel with the deployment. Use a home-directory path only for a tool people install and personalise.",{"breadcrumb":2244},[2245,2248,2251,2252],{"name":2246,"item":2247},"Home","\u002F",{"name":2249,"item":2250},"Automating Reporting Workflows","\u002Fautomating-reporting-workflows\u002F",{"name":20,"item":19},{"name":5,"item":2253},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file\u002F","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file",{"title":2256,"description":2257},"Config Files for Python Excel Report Scripts","Store Excel report settings outside the code with TOML or JSON, layer environment variables and CLI flags on top, keep secrets out of version control, and validate at startup.","keep-excel-report-settings-in-a-config-file","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file\u002Findex","how-to","Jzi3zqI_febDvs1vJgOV7sgt4uoXaiuRtuDKgb7Ouho",[2263,2266],{"title":2191,"path":2264,"stem":2265,"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",{"title":2198,"path":2267,"stem":2268,"children":-1},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller\u002Findex",1786800028338]