[{"data":1,"prerenderedAt":2168},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed":3,"surround:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed":2133,"ancestors:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed":2142,"children:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed":2147,"related:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed":2148},{"id":4,"title":5,"body":6,"dateModified":2100,"datePublished":2100,"description":2101,"extension":2102,"faq":2103,"keyword":2112,"kind":2113,"meta":2114,"navigation":278,"path":2122,"seo":2123,"slug":2126,"stem":2127,"topics":2128,"type":2131,"__hash__":2132},"docs\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed\u002Findex.md","Skip a Report Run When the Input File Has Not Changed",{"type":7,"value":8,"toc":2087},"minimark",[9,19,169,174,202,205,461,465,468,570,581,585,592,822,829,993,997,1000,1216,1219,1223,1226,1532,1539,1654,1658,1669,1798,1801,1805,1819,1964,1972,1976,2015,2019,2022,2026,2032,2038,2044,2050,2054,2083],[10,11,12,13,18],"p",{},"A report scheduled every hour to catch a file that arrives once a day rebuilds the same workbook twenty-three times and, worse, may email it twenty-three times. The obvious fix — compare the file's hash with the last run's — fails with Excel, because saving a workbook changes its bytes even when no value changed. This guide, part of ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","Error Handling and Logging in Excel Automation",", builds a change check that looks at the data rather than the file, keeps it cheap for large inputs, and makes sure a failed run is never mistaken for a finished one.",[20,21,29,30,29,34,29,38,29,46,29,54,29,64,29,75,29,82,29,87,29,91,29,95,29,101,29,108,29,114,29,118,29,120,29,122,29,127,29,134,29,139,29,142,29,144,29,146,29,151,29,157,29,162,29,165,29,167],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"4 0 752 279","img",[25,26],"sk-week-t","sk-week-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[31,32,33],"title",{"id":25},"Four scheduled runs, two reports",[35,36,37],"desc",{"id":26},"On Monday the input is new, so the report runs. On Tuesday nothing has touched the file, so the run is skipped after a metadata check. On Wednesday someone opened and re-saved the file without changing any value; the file's bytes changed but the data fingerprint did not, so the run is skipped again. On Thursday a value changed and the report runs.",[39,40],"rect",{"x":41,"y":42,"width":43,"height":44,"fill":45},"4","0","752","279","#ffffff",[47,48],"line",{"x1":49,"y1":50,"x2":51,"y2":50,"stroke":52,"style":53},"94.0","139.5","666.0","var(--muted,#5b6780)","stroke-width:2.5px",[39,55],{"x":56,"y":57,"width":58,"height":59,"rx":60,"fill":61,"stroke":62,"style":63},"20.0","16.0","172.0","91.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[65,66,68],"g",{"transform":67},"translate(94.0,29.0) scale(1.000)",[69,70],"path",{"d":71,"fill":72,"stroke":73,"style":74},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[76,77,81],"text",{"x":78,"y":79,"style":80},"106.0","74.5","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Mon",[76,83,86],{"x":78,"y":84,"style":85},"93.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","new input → run",[47,88],{"x1":78,"y1":89,"x2":78,"y2":50,"stroke":52,"style":90},"107.5","stroke-width:1.5px",[92,93],"circle",{"cx":78,"cy":50,"r":94,"fill":62,"stroke":45,"style":63},"7.0",[39,96],{"x":97,"y":98,"width":58,"height":59,"rx":60,"fill":99,"stroke":100,"style":63},"202.7","171.5","#e7ebef","var(--line,#cdd5e6)",[65,102,104],{"transform":103},"translate(276.7,184.5) scale(1.000)",[69,105],{"d":106,"fill":72,"stroke":107,"style":74},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2","var(--text,#172033)",[76,109,113],{"x":110,"y":111,"style":112},"288.7","230.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Tue",[76,115,117],{"x":110,"y":116,"style":85},"248.5","untouched → skip",[47,119],{"x1":110,"y1":50,"x2":110,"y2":98,"stroke":52,"style":90},[92,121],{"cx":110,"cy":50,"r":94,"fill":52,"stroke":45,"style":63},[39,123],{"x":124,"y":57,"width":58,"height":59,"rx":60,"fill":125,"stroke":126,"style":63},"385.3","#fdefd8","var(--gold,#b4740a)",[65,128,130],{"transform":129},"translate(459.3,29.0) scale(1.000)",[69,131],{"d":132,"fill":72,"stroke":133,"style":74},"M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 .01 0z","var(--gold-ink,#7a4e06)",[76,135,138],{"x":136,"y":79,"style":137},"471.3","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Wed",[76,140,141],{"x":136,"y":84,"style":85},"re-saved only → skip",[47,143],{"x1":136,"y1":89,"x2":136,"y2":50,"stroke":52,"style":90},[92,145],{"cx":136,"cy":50,"r":94,"fill":126,"stroke":45,"style":63},[39,147],{"x":148,"y":98,"width":58,"height":59,"rx":60,"fill":149,"stroke":150,"style":63},"568.0","#d9f4f1","var(--teal,#0f9488)",[65,152,154],{"transform":153},"translate(642.0,184.5) scale(1.000)",[69,155],{"d":71,"fill":72,"stroke":156,"style":74},"var(--teal-ink,#0b6157)",[76,158,161],{"x":159,"y":111,"style":160},"654.0","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Thu",[76,163,164],{"x":159,"y":116,"style":85},"value changed → run",[47,166],{"x1":159,"y1":50,"x2":159,"y2":98,"stroke":52,"style":90},[92,168],{"cx":159,"cy":50,"r":94,"fill":150,"stroke":45,"style":63},[170,171,173],"h2",{"id":172},"prerequisites","Prerequisites",[175,176,181],"pre",{"className":177,"code":178,"language":179,"meta":180,"style":180},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[182,183,184],"code",{"__ignoreMap":180},[185,186,188,192,196,199],"span",{"class":47,"line":187},1,[185,189,191],{"class":190},"sMTad","pip",[185,193,195],{"class":194},"srMev"," install",[185,197,198],{"class":194}," pandas",[185,200,201],{"class":194}," openpyxl\n",[10,203,204],{},"A sample input, written the way an upstream export would write it:",[175,206,210],{"className":207,"code":208,"language":209,"meta":180,"style":180},"language-python shiki shiki-themes github-light github-dark-high-contrast","import hashlib\nimport json\nimport os\nimport time\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nimport pandas as pd\n\norders = pd.DataFrame({\n    \"order_id\": range(1, 201),\n    \"region\": [\"North\", \"South\", \"East\", \"West\"] * 50,\n    \"amount\": [round(20 + i * 1.7, 2) for i in range(200)],\n})\norders.to_excel(\"orders.xlsx\", index=False)\n","python",[182,211,212,222,230,238,246,260,273,280,294,299,311,339,378,432,438],{"__ignoreMap":180},[185,213,214,218],{"class":47,"line":187},[185,215,217],{"class":216},"s-kum","import",[185,219,221],{"class":220},"skGVy"," hashlib\n",[185,223,225,227],{"class":47,"line":224},2,[185,226,217],{"class":216},[185,228,229],{"class":220}," json\n",[185,231,233,235],{"class":47,"line":232},3,[185,234,217],{"class":216},[185,236,237],{"class":220}," os\n",[185,239,241,243],{"class":47,"line":240},4,[185,242,217],{"class":216},[185,244,245],{"class":220}," time\n",[185,247,249,252,255,257],{"class":47,"line":248},5,[185,250,251],{"class":216},"from",[185,253,254],{"class":220}," datetime ",[185,256,217],{"class":216},[185,258,259],{"class":220}," datetime, timedelta, timezone\n",[185,261,263,265,268,270],{"class":47,"line":262},6,[185,264,251],{"class":216},[185,266,267],{"class":220}," pathlib ",[185,269,217],{"class":216},[185,271,272],{"class":220}," Path\n",[185,274,276],{"class":47,"line":275},7,[185,277,279],{"emptyLinePlaceholder":278},true,"\n",[185,281,283,285,288,291],{"class":47,"line":282},8,[185,284,217],{"class":216},[185,286,287],{"class":220}," pandas ",[185,289,290],{"class":216},"as",[185,292,293],{"class":220}," pd\n",[185,295,297],{"class":47,"line":296},9,[185,298,279],{"emptyLinePlaceholder":278},[185,300,302,305,308],{"class":47,"line":301},10,[185,303,304],{"class":220},"orders ",[185,306,307],{"class":216},"=",[185,309,310],{"class":220}," pd.DataFrame({\n",[185,312,314,317,320,324,327,330,333,336],{"class":47,"line":313},11,[185,315,316],{"class":194},"    \"order_id\"",[185,318,319],{"class":220},": ",[185,321,323],{"class":322},"sP0c6","range",[185,325,326],{"class":220},"(",[185,328,329],{"class":322},"1",[185,331,332],{"class":220},", ",[185,334,335],{"class":322},"201",[185,337,338],{"class":220},"),\n",[185,340,342,345,348,351,353,356,358,361,363,366,369,372,375],{"class":47,"line":341},12,[185,343,344],{"class":194},"    \"region\"",[185,346,347],{"class":220},": [",[185,349,350],{"class":194},"\"North\"",[185,352,332],{"class":220},[185,354,355],{"class":194},"\"South\"",[185,357,332],{"class":220},[185,359,360],{"class":194},"\"East\"",[185,362,332],{"class":220},[185,364,365],{"class":194},"\"West\"",[185,367,368],{"class":220},"] ",[185,370,371],{"class":216},"*",[185,373,374],{"class":322}," 50",[185,376,377],{"class":220},",\n",[185,379,381,384,386,389,391,394,397,400,402,405,407,410,413,416,418,421,424,426,429],{"class":47,"line":380},13,[185,382,383],{"class":194},"    \"amount\"",[185,385,347],{"class":220},[185,387,388],{"class":322},"round",[185,390,326],{"class":220},[185,392,393],{"class":322},"20",[185,395,396],{"class":216}," +",[185,398,399],{"class":220}," i ",[185,401,371],{"class":216},[185,403,404],{"class":322}," 1.7",[185,406,332],{"class":220},[185,408,409],{"class":322},"2",[185,411,412],{"class":220},") ",[185,414,415],{"class":216},"for",[185,417,399],{"class":220},[185,419,420],{"class":216},"in",[185,422,423],{"class":322}," range",[185,425,326],{"class":220},[185,427,428],{"class":322},"200",[185,430,431],{"class":220},")],\n",[185,433,435],{"class":47,"line":434},14,[185,436,437],{"class":220},"})\n",[185,439,441,444,447,449,453,455,458],{"class":47,"line":440},15,[185,442,443],{"class":220},"orders.to_excel(",[185,445,446],{"class":194},"\"orders.xlsx\"",[185,448,332],{"class":220},[185,450,452],{"class":451},"sa561","index",[185,454,307],{"class":216},[185,456,457],{"class":322},"False",[185,459,460],{"class":220},")\n",[170,462,464],{"id":463},"step-1-why-the-file-hash-is-the-wrong-fingerprint","Step 1 — Why the file hash is the wrong fingerprint",[10,466,467],{},"Hashing the bytes looks like the natural check. Save the same DataFrame again a moment later and compare:",[175,469,471],{"className":207,"code":470,"language":209,"meta":180,"style":180},"def file_hash(path):\n    return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:12]\n\nfirst = file_hash(\"orders.xlsx\")\ntime.sleep(1.1)\norders.to_excel(\"orders.xlsx\", index=False)                     # same data, saved again\nprint(first == file_hash(\"orders.xlsx\"))\n# False\n",[182,472,473,485,498,502,516,526,547,565],{"__ignoreMap":180},[185,474,475,478,482],{"class":47,"line":187},[185,476,477],{"class":216},"def",[185,479,481],{"class":480},"s_Opv"," file_hash",[185,483,484],{"class":220},"(path):\n",[185,486,487,490,493,495],{"class":47,"line":224},[185,488,489],{"class":216},"    return",[185,491,492],{"class":220}," hashlib.sha256(Path(path).read_bytes()).hexdigest()[:",[185,494,60],{"class":322},[185,496,497],{"class":220},"]\n",[185,499,500],{"class":47,"line":232},[185,501,279],{"emptyLinePlaceholder":278},[185,503,504,507,509,512,514],{"class":47,"line":240},[185,505,506],{"class":220},"first ",[185,508,307],{"class":216},[185,510,511],{"class":220}," file_hash(",[185,513,446],{"class":194},[185,515,460],{"class":220},[185,517,518,521,524],{"class":47,"line":248},[185,519,520],{"class":220},"time.sleep(",[185,522,523],{"class":322},"1.1",[185,525,460],{"class":220},[185,527,528,530,532,534,536,538,540,543],{"class":47,"line":262},[185,529,443],{"class":220},[185,531,446],{"class":194},[185,533,332],{"class":220},[185,535,452],{"class":451},[185,537,307],{"class":216},[185,539,457],{"class":322},[185,541,542],{"class":220},")                     ",[185,544,546],{"class":545},"s-wDw","# same data, saved again\n",[185,548,549,552,555,558,560,562],{"class":47,"line":275},[185,550,551],{"class":322},"print",[185,553,554],{"class":220},"(first ",[185,556,557],{"class":216},"==",[185,559,511],{"class":220},[185,561,446],{"class":194},[185,563,564],{"class":220},"))\n",[185,566,567],{"class":47,"line":282},[185,568,569],{"class":545},"# False\n",[10,571,572,573,576,577,580],{},"An ",[182,574,575],{},".xlsx"," records when it was saved in ",[182,578,579],{},"docProps\u002Fcore.xml",", so every save produces new bytes. In testing, excluding that part made two saves from the same library match — but a re-save through another application (LibreOffice here, Excel in practice) rewrote styles and other parts, and the hash changed again. Someone opening the export to look at it and pressing Ctrl+S is enough to trigger a full rerun.",[170,582,584],{"id":583},"step-2-fingerprint-the-data-instead","Step 2 — Fingerprint the data instead",[10,586,587,588,591],{},"Read every sheet and hash what the report actually uses: sheet names, column names and values. ",[182,589,590],{},"pandas.util.hash_pandas_object"," hashes each row quickly and deterministically:",[175,593,595],{"className":207,"code":594,"language":209,"meta":180,"style":180},"def data_fingerprint(path):\n    digest = hashlib.sha256()\n    for name, frame in sorted(pd.read_excel(path, sheet_name=None).items()):\n        digest.update(name.encode())\n        digest.update(json.dumps([str(c) for c in frame.columns]).encode())\n        digest.update(pd.util.hash_pandas_object(frame, index=False).values.tobytes())\n    return digest.hexdigest()[:16]\n\nbefore = data_fingerprint(\"orders.xlsx\")\norders.to_excel(\"orders.xlsx\", index=False)                     # re-save: same data\nprint(before == data_fingerprint(\"orders.xlsx\"))\n# True\nchanged = orders.copy()\nchanged.loc[4, \"amount\"] = 999.0\nchanged.to_excel(\"orders.xlsx\", index=False)                    # one value changed\nprint(before == data_fingerprint(\"orders.xlsx\"))\n# False\n",[182,596,597,606,616,643,648,669,683,695,699,713,732,747,752,762,781,802,817],{"__ignoreMap":180},[185,598,599,601,604],{"class":47,"line":187},[185,600,477],{"class":216},[185,602,603],{"class":480}," data_fingerprint",[185,605,484],{"class":220},[185,607,608,611,613],{"class":47,"line":224},[185,609,610],{"class":220},"    digest ",[185,612,307],{"class":216},[185,614,615],{"class":220}," hashlib.sha256()\n",[185,617,618,621,624,626,629,632,635,637,640],{"class":47,"line":232},[185,619,620],{"class":216},"    for",[185,622,623],{"class":220}," name, frame ",[185,625,420],{"class":216},[185,627,628],{"class":322}," sorted",[185,630,631],{"class":220},"(pd.read_excel(path, ",[185,633,634],{"class":451},"sheet_name",[185,636,307],{"class":216},[185,638,639],{"class":322},"None",[185,641,642],{"class":220},").items()):\n",[185,644,645],{"class":47,"line":240},[185,646,647],{"class":220},"        digest.update(name.encode())\n",[185,649,650,653,656,659,661,664,666],{"class":47,"line":248},[185,651,652],{"class":220},"        digest.update(json.dumps([",[185,654,655],{"class":322},"str",[185,657,658],{"class":220},"(c) ",[185,660,415],{"class":216},[185,662,663],{"class":220}," c ",[185,665,420],{"class":216},[185,667,668],{"class":220}," frame.columns]).encode())\n",[185,670,671,674,676,678,680],{"class":47,"line":262},[185,672,673],{"class":220},"        digest.update(pd.util.hash_pandas_object(frame, ",[185,675,452],{"class":451},[185,677,307],{"class":216},[185,679,457],{"class":322},[185,681,682],{"class":220},").values.tobytes())\n",[185,684,685,687,690,693],{"class":47,"line":275},[185,686,489],{"class":216},[185,688,689],{"class":220}," digest.hexdigest()[:",[185,691,692],{"class":322},"16",[185,694,497],{"class":220},[185,696,697],{"class":47,"line":282},[185,698,279],{"emptyLinePlaceholder":278},[185,700,701,704,706,709,711],{"class":47,"line":296},[185,702,703],{"class":220},"before ",[185,705,307],{"class":216},[185,707,708],{"class":220}," data_fingerprint(",[185,710,446],{"class":194},[185,712,460],{"class":220},[185,714,715,717,719,721,723,725,727,729],{"class":47,"line":301},[185,716,443],{"class":220},[185,718,446],{"class":194},[185,720,332],{"class":220},[185,722,452],{"class":451},[185,724,307],{"class":216},[185,726,457],{"class":322},[185,728,542],{"class":220},[185,730,731],{"class":545},"# re-save: same data\n",[185,733,734,736,739,741,743,745],{"class":47,"line":313},[185,735,551],{"class":322},[185,737,738],{"class":220},"(before ",[185,740,557],{"class":216},[185,742,708],{"class":220},[185,744,446],{"class":194},[185,746,564],{"class":220},[185,748,749],{"class":47,"line":341},[185,750,751],{"class":545},"# True\n",[185,753,754,757,759],{"class":47,"line":380},[185,755,756],{"class":220},"changed ",[185,758,307],{"class":216},[185,760,761],{"class":220}," orders.copy()\n",[185,763,764,767,769,771,774,776,778],{"class":47,"line":434},[185,765,766],{"class":220},"changed.loc[",[185,768,41],{"class":322},[185,770,332],{"class":220},[185,772,773],{"class":194},"\"amount\"",[185,775,368],{"class":220},[185,777,307],{"class":216},[185,779,780],{"class":322}," 999.0\n",[185,782,783,786,788,790,792,794,796,799],{"class":47,"line":440},[185,784,785],{"class":220},"changed.to_excel(",[185,787,446],{"class":194},[185,789,332],{"class":220},[185,791,452],{"class":451},[185,793,307],{"class":216},[185,795,457],{"class":322},[185,797,798],{"class":220},")                    ",[185,800,801],{"class":545},"# one value changed\n",[185,803,805,807,809,811,813,815],{"class":47,"line":804},16,[185,806,551],{"class":322},[185,808,738],{"class":220},[185,810,557],{"class":216},[185,812,708],{"class":220},[185,814,446],{"class":194},[185,816,564],{"class":220},[185,818,820],{"class":47,"line":819},17,[185,821,569],{"class":545},[10,823,824,825,828],{},"The same fingerprint survived a LibreOffice re-save in testing, where every byte-level hash changed. Hash only what the report reads: if it uses one sheet, pass ",[182,826,827],{},"sheet_name=\"Orders\"","; if a volatile cell such as a \"generated at\" timestamp sits on the sheet, drop it before hashing, or every export looks new.",[20,830,29,835,29,838,29,841,29,844,29,849,29,854,29,860,29,864,29,867,29,871,29,874,29,877,29,882,29,885,29,889,29,892,29,898,29,900,29,905,29,907,29,909,29,911,29,913,29,915,29,918,29,921,29,925,29,927,29,931,29,933,29,935,29,937,29,939,29,941,29,943,29,946,29,950,29,952,29,955,29,957,29,959,29,961,29,963,29,965,29,968,29,971,29,975,29,977,29,980,29,982,29,984,29,986,29,988,29,990],{"viewBox":831,"role":23,"ariaLabelledBy":832,"xmlns":27,"style":28},"4 0 752 208",[833,834],"sk-prints-t","sk-prints-d",[31,836,837],{"id":833},"What each fingerprint notices",[35,839,840],{"id":834},"The file hash changes on any save, even with identical data. A hash of the workbook parts without docProps survives re-saves by the same library but not by another application. The data fingerprint changes only when sheet names, column names or values change, at the cost of reading the whole file. Size and modified time cost nothing and are used as a fast first check.",[39,842],{"x":41,"y":42,"width":43,"height":843,"fill":45},"208",[39,845],{"x":56,"y":57,"width":846,"height":847,"rx":42,"fill":45,"stroke":100,"style":848},"177.9","40.0","stroke-width:1px",[39,850],{"x":851,"y":57,"width":852,"height":847,"rx":42,"fill":61,"stroke":62,"style":853},"197.9","135.5","stroke-width:1.2px",[76,855,859],{"x":856,"y":857,"style":858},"265.6","34.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Same data",[76,861,863],{"x":856,"y":862,"style":858},"49.0","re-saved",[39,865],{"x":866,"y":57,"width":852,"height":847,"rx":42,"fill":61,"stroke":62,"style":853},"333.4",[76,868,870],{"x":869,"y":857,"style":858},"401.2","Other app",[76,872,873],{"x":869,"y":862,"style":858},"re-saves",[39,875],{"x":876,"y":57,"width":852,"height":847,"rx":42,"fill":61,"stroke":62,"style":853},"468.9",[76,878,881],{"x":879,"y":880,"style":858},"536.7","41.0","Value changed",[39,883],{"x":884,"y":57,"width":852,"height":847,"rx":42,"fill":61,"stroke":62,"style":853},"604.5",[76,886,888],{"x":887,"y":880,"style":858},"672.2","Cost",[39,890],{"x":56,"y":891,"width":846,"height":857,"rx":42,"fill":99,"stroke":100,"style":848},"56.0",[76,893,897],{"x":894,"y":895,"style":896},"32.0","78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","file hash",[39,899],{"x":851,"y":891,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,901,904],{"x":856,"y":902,"style":903},"77.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","changes",[39,906],{"x":866,"y":891,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,908,904],{"x":869,"y":902,"style":903},[39,910],{"x":876,"y":891,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,912,904],{"x":879,"y":902,"style":903},[39,914],{"x":884,"y":891,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,916,917],{"x":887,"y":902,"style":903},"one read of the bytes",[39,919],{"x":56,"y":920,"width":846,"height":857,"rx":42,"fill":99,"stroke":100,"style":848},"90.0",[76,922,924],{"x":894,"y":923,"style":896},"112.0","parts without docProps",[39,926],{"x":851,"y":920,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,928,930],{"x":856,"y":929,"style":903},"111.0","same",[39,932],{"x":866,"y":920,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,934,904],{"x":869,"y":929,"style":903},[39,936],{"x":876,"y":920,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,938,904],{"x":879,"y":929,"style":903},[39,940],{"x":884,"y":920,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,942,917],{"x":887,"y":929,"style":903},[39,944],{"x":56,"y":945,"width":846,"height":857,"rx":42,"fill":99,"stroke":100,"style":848},"124.0",[76,947,949],{"x":894,"y":948,"style":896},"146.0","data fingerprint",[39,951],{"x":851,"y":945,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,953,930],{"x":856,"y":954,"style":903},"145.0",[39,956],{"x":866,"y":945,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,958,930],{"x":869,"y":954,"style":903},[39,960],{"x":876,"y":945,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,962,904],{"x":879,"y":954,"style":903},[39,964],{"x":884,"y":945,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,966,967],{"x":887,"y":954,"style":903},"full pandas read",[39,969],{"x":56,"y":970,"width":846,"height":857,"rx":42,"fill":99,"stroke":100,"style":848},"158.0",[76,972,974],{"x":894,"y":973,"style":896},"180.0","size + modified time",[39,976],{"x":851,"y":970,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,978,904],{"x":856,"y":979,"style":903},"179.0",[39,981],{"x":866,"y":970,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,983,904],{"x":869,"y":979,"style":903},[39,985],{"x":876,"y":970,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,987,904],{"x":879,"y":979,"style":903},[39,989],{"x":884,"y":970,"width":852,"height":857,"rx":42,"fill":45,"stroke":100,"style":848},[76,991,992],{"x":887,"y":979,"style":903},"free",[170,994,996],{"id":995},"step-3-check-metadata-first-hash-only-when-needed","Step 3 — Check metadata first, hash only when needed",[10,998,999],{},"Reading a large workbook just to discover it has not changed wastes the time the check was meant to save. File size and modification time cost nothing: if both match the last run, the file was not touched and there is nothing to read. Only when they differ does the data fingerprint decide:",[175,1001,1003],{"className":207,"code":1002,"language":209,"meta":180,"style":180},"STATE = Path(\"report_state.json\")\n\ndef load_state():\n    return json.loads(STATE.read_text()) if STATE.exists() else {}\n\ndef check_input(path):\n    \"\"\"Return (needs_run, record) where record describes the input now.\"\"\"\n    stat = os.stat(path)\n    seen = load_state().get(str(path), {})\n    meta = {\"size\": stat.st_size, \"mtime_ns\": stat.st_mtime_ns}\n    if seen and all(seen.get(k) == v for k, v in meta.items()):\n        return False, seen                                      # untouched: no read at all\n    record = {**meta, \"data\": data_fingerprint(path)}\n    return record[\"data\"] != seen.get(\"data\"), record\n",[182,1004,1005,1021,1025,1035,1062,1066,1075,1080,1090,1105,1127,1159,1173,1194],{"__ignoreMap":180},[185,1006,1007,1010,1013,1016,1019],{"class":47,"line":187},[185,1008,1009],{"class":322},"STATE",[185,1011,1012],{"class":216}," =",[185,1014,1015],{"class":220}," Path(",[185,1017,1018],{"class":194},"\"report_state.json\"",[185,1020,460],{"class":220},[185,1022,1023],{"class":47,"line":224},[185,1024,279],{"emptyLinePlaceholder":278},[185,1026,1027,1029,1032],{"class":47,"line":232},[185,1028,477],{"class":216},[185,1030,1031],{"class":480}," load_state",[185,1033,1034],{"class":220},"():\n",[185,1036,1037,1039,1042,1044,1047,1050,1053,1056,1059],{"class":47,"line":240},[185,1038,489],{"class":216},[185,1040,1041],{"class":220}," json.loads(",[185,1043,1009],{"class":322},[185,1045,1046],{"class":220},".read_text()) ",[185,1048,1049],{"class":216},"if",[185,1051,1052],{"class":322}," STATE",[185,1054,1055],{"class":220},".exists() ",[185,1057,1058],{"class":216},"else",[185,1060,1061],{"class":220}," {}\n",[185,1063,1064],{"class":47,"line":248},[185,1065,279],{"emptyLinePlaceholder":278},[185,1067,1068,1070,1073],{"class":47,"line":262},[185,1069,477],{"class":216},[185,1071,1072],{"class":480}," check_input",[185,1074,484],{"class":220},[185,1076,1077],{"class":47,"line":275},[185,1078,1079],{"class":194},"    \"\"\"Return (needs_run, record) where record describes the input now.\"\"\"\n",[185,1081,1082,1085,1087],{"class":47,"line":282},[185,1083,1084],{"class":220},"    stat ",[185,1086,307],{"class":216},[185,1088,1089],{"class":220}," os.stat(path)\n",[185,1091,1092,1095,1097,1100,1102],{"class":47,"line":296},[185,1093,1094],{"class":220},"    seen ",[185,1096,307],{"class":216},[185,1098,1099],{"class":220}," load_state().get(",[185,1101,655],{"class":322},[185,1103,1104],{"class":220},"(path), {})\n",[185,1106,1107,1110,1112,1115,1118,1121,1124],{"class":47,"line":301},[185,1108,1109],{"class":220},"    meta ",[185,1111,307],{"class":216},[185,1113,1114],{"class":220}," {",[185,1116,1117],{"class":194},"\"size\"",[185,1119,1120],{"class":220},": stat.st_size, ",[185,1122,1123],{"class":194},"\"mtime_ns\"",[185,1125,1126],{"class":220},": stat.st_mtime_ns}\n",[185,1128,1129,1132,1135,1138,1141,1144,1146,1149,1151,1154,1156],{"class":47,"line":313},[185,1130,1131],{"class":216},"    if",[185,1133,1134],{"class":220}," seen ",[185,1136,1137],{"class":216},"and",[185,1139,1140],{"class":322}," all",[185,1142,1143],{"class":220},"(seen.get(k) ",[185,1145,557],{"class":216},[185,1147,1148],{"class":220}," v ",[185,1150,415],{"class":216},[185,1152,1153],{"class":220}," k, v ",[185,1155,420],{"class":216},[185,1157,1158],{"class":220}," meta.items()):\n",[185,1160,1161,1164,1167,1170],{"class":47,"line":341},[185,1162,1163],{"class":216},"        return",[185,1165,1166],{"class":322}," False",[185,1168,1169],{"class":220},", seen                                      ",[185,1171,1172],{"class":545},"# untouched: no read at all\n",[185,1174,1175,1178,1180,1182,1185,1188,1191],{"class":47,"line":380},[185,1176,1177],{"class":220},"    record ",[185,1179,307],{"class":216},[185,1181,1114],{"class":220},[185,1183,1184],{"class":216},"**",[185,1186,1187],{"class":220},"meta, ",[185,1189,1190],{"class":194},"\"data\"",[185,1192,1193],{"class":220},": data_fingerprint(path)}\n",[185,1195,1196,1198,1201,1203,1205,1208,1211,1213],{"class":47,"line":434},[185,1197,489],{"class":216},[185,1199,1200],{"class":220}," record[",[185,1202,1190],{"class":194},[185,1204,368],{"class":220},[185,1206,1207],{"class":216},"!=",[185,1209,1210],{"class":220}," seen.get(",[185,1212,1190],{"class":194},[185,1214,1215],{"class":220},"), record\n",[10,1217,1218],{},"When the metadata changed but the data did not, the run is skipped and the new size and time are saved, so the next check takes the fast path again.",[170,1220,1222],{"id":1221},"step-4-record-the-fingerprint-only-after-success","Step 4 — Record the fingerprint only after success",[10,1224,1225],{},"The order matters. Save the fingerprint before building the report, and a run that crashes halfway marks the input as processed — the next run skips it, and the report never arrives. Save it after the output is written and delivered:",[175,1227,1229],{"className":207,"code":1228,"language":209,"meta":180,"style":180},"def save_record(path, record):\n    state = load_state()\n    state[str(path)] = {**state.get(str(path), {}), **record}   # keeps changed_at\n    tmp = STATE.with_suffix(\".tmp\")\n    tmp.write_text(json.dumps(state, indent=2))\n    tmp.replace(STATE)                                          # atomic on the same disk\n\ndef build_report(path):\n    summary = pd.read_excel(path).groupby(\"region\")[\"amount\"].sum()\n    summary.to_excel(\"region_summary.xlsx\")\n\ndef run(path, force=False):\n    needs_run, record = check_input(path)\n    if not (needs_run or force):\n        save_record(path, record)                               # refresh size\u002Fmtime\n        return \"skipped\"\n    build_report(path)                                          # raises → nothing recorded\n    save_record(path, {**record, \"changed_at\": datetime.now(timezone.utc).isoformat()})\n    return \"ran\"\n\nprint(run(\"orders.xlsx\"))                                       # ran\nprint(run(\"orders.xlsx\"))                                       # skipped: untouched\nchanged.to_excel(\"orders.xlsx\", index=False)\nprint(run(\"orders.xlsx\"))                                       # skipped: re-saved only\n",[182,1230,1231,1241,1251,1283,1300,1314,1327,1331,1340,1361,1371,1375,1392,1402,1418,1426,1433,1441,1458,1466,1471,1487,1501,1518],{"__ignoreMap":180},[185,1232,1233,1235,1238],{"class":47,"line":187},[185,1234,477],{"class":216},[185,1236,1237],{"class":480}," save_record",[185,1239,1240],{"class":220},"(path, record):\n",[185,1242,1243,1246,1248],{"class":47,"line":224},[185,1244,1245],{"class":220},"    state ",[185,1247,307],{"class":216},[185,1249,1250],{"class":220}," load_state()\n",[185,1252,1253,1256,1258,1261,1263,1265,1267,1270,1272,1275,1277,1280],{"class":47,"line":232},[185,1254,1255],{"class":220},"    state[",[185,1257,655],{"class":322},[185,1259,1260],{"class":220},"(path)] ",[185,1262,307],{"class":216},[185,1264,1114],{"class":220},[185,1266,1184],{"class":216},[185,1268,1269],{"class":220},"state.get(",[185,1271,655],{"class":322},[185,1273,1274],{"class":220},"(path), {}), ",[185,1276,1184],{"class":216},[185,1278,1279],{"class":220},"record}   ",[185,1281,1282],{"class":545},"# keeps changed_at\n",[185,1284,1285,1288,1290,1292,1295,1298],{"class":47,"line":240},[185,1286,1287],{"class":220},"    tmp ",[185,1289,307],{"class":216},[185,1291,1052],{"class":322},[185,1293,1294],{"class":220},".with_suffix(",[185,1296,1297],{"class":194},"\".tmp\"",[185,1299,460],{"class":220},[185,1301,1302,1305,1308,1310,1312],{"class":47,"line":248},[185,1303,1304],{"class":220},"    tmp.write_text(json.dumps(state, ",[185,1306,1307],{"class":451},"indent",[185,1309,307],{"class":216},[185,1311,409],{"class":322},[185,1313,564],{"class":220},[185,1315,1316,1319,1321,1324],{"class":47,"line":262},[185,1317,1318],{"class":220},"    tmp.replace(",[185,1320,1009],{"class":322},[185,1322,1323],{"class":220},")                                          ",[185,1325,1326],{"class":545},"# atomic on the same disk\n",[185,1328,1329],{"class":47,"line":275},[185,1330,279],{"emptyLinePlaceholder":278},[185,1332,1333,1335,1338],{"class":47,"line":282},[185,1334,477],{"class":216},[185,1336,1337],{"class":480}," build_report",[185,1339,484],{"class":220},[185,1341,1342,1345,1347,1350,1353,1356,1358],{"class":47,"line":296},[185,1343,1344],{"class":220},"    summary ",[185,1346,307],{"class":216},[185,1348,1349],{"class":220}," pd.read_excel(path).groupby(",[185,1351,1352],{"class":194},"\"region\"",[185,1354,1355],{"class":220},")[",[185,1357,773],{"class":194},[185,1359,1360],{"class":220},"].sum()\n",[185,1362,1363,1366,1369],{"class":47,"line":301},[185,1364,1365],{"class":220},"    summary.to_excel(",[185,1367,1368],{"class":194},"\"region_summary.xlsx\"",[185,1370,460],{"class":220},[185,1372,1373],{"class":47,"line":313},[185,1374,279],{"emptyLinePlaceholder":278},[185,1376,1377,1379,1382,1385,1387,1389],{"class":47,"line":341},[185,1378,477],{"class":216},[185,1380,1381],{"class":480}," run",[185,1383,1384],{"class":220},"(path, force",[185,1386,307],{"class":216},[185,1388,457],{"class":322},[185,1390,1391],{"class":220},"):\n",[185,1393,1394,1397,1399],{"class":47,"line":380},[185,1395,1396],{"class":220},"    needs_run, record ",[185,1398,307],{"class":216},[185,1400,1401],{"class":220}," check_input(path)\n",[185,1403,1404,1406,1409,1412,1415],{"class":47,"line":434},[185,1405,1131],{"class":216},[185,1407,1408],{"class":216}," not",[185,1410,1411],{"class":220}," (needs_run ",[185,1413,1414],{"class":216},"or",[185,1416,1417],{"class":220}," force):\n",[185,1419,1420,1423],{"class":47,"line":440},[185,1421,1422],{"class":220},"        save_record(path, record)                               ",[185,1424,1425],{"class":545},"# refresh size\u002Fmtime\n",[185,1427,1428,1430],{"class":47,"line":804},[185,1429,1163],{"class":216},[185,1431,1432],{"class":194}," \"skipped\"\n",[185,1434,1435,1438],{"class":47,"line":819},[185,1436,1437],{"class":220},"    build_report(path)                                          ",[185,1439,1440],{"class":545},"# raises → nothing recorded\n",[185,1442,1444,1447,1449,1452,1455],{"class":47,"line":1443},18,[185,1445,1446],{"class":220},"    save_record(path, {",[185,1448,1184],{"class":216},[185,1450,1451],{"class":220},"record, ",[185,1453,1454],{"class":194},"\"changed_at\"",[185,1456,1457],{"class":220},": datetime.now(timezone.utc).isoformat()})\n",[185,1459,1461,1463],{"class":47,"line":1460},19,[185,1462,489],{"class":216},[185,1464,1465],{"class":194}," \"ran\"\n",[185,1467,1469],{"class":47,"line":1468},20,[185,1470,279],{"emptyLinePlaceholder":278},[185,1472,1474,1476,1479,1481,1484],{"class":47,"line":1473},21,[185,1475,551],{"class":322},[185,1477,1478],{"class":220},"(run(",[185,1480,446],{"class":194},[185,1482,1483],{"class":220},"))                                       ",[185,1485,1486],{"class":545},"# ran\n",[185,1488,1490,1492,1494,1496,1498],{"class":47,"line":1489},22,[185,1491,551],{"class":322},[185,1493,1478],{"class":220},[185,1495,446],{"class":194},[185,1497,1483],{"class":220},[185,1499,1500],{"class":545},"# skipped: untouched\n",[185,1502,1504,1506,1508,1510,1512,1514,1516],{"class":47,"line":1503},23,[185,1505,785],{"class":220},[185,1507,446],{"class":194},[185,1509,332],{"class":220},[185,1511,452],{"class":451},[185,1513,307],{"class":216},[185,1515,457],{"class":322},[185,1517,460],{"class":220},[185,1519,1521,1523,1525,1527,1529],{"class":47,"line":1520},24,[185,1522,551],{"class":322},[185,1524,1478],{"class":220},[185,1526,446],{"class":194},[185,1528,1483],{"class":220},[185,1530,1531],{"class":545},"# skipped: re-saved only\n",[10,1533,1534,1535,1538],{},"Writing to a temporary file and replacing the state file keeps it valid if the process dies mid-write. The ",[182,1536,1537],{},"force"," flag gives people a way to resend a report on purpose — after fixing a template, for example — without deleting the state file.",[20,1540,29,1545,29,1548,29,1551,29,1554,29,1559,29,1565,29,1570,29,1573,29,1577,29,1580,29,1585,29,1592,29,1597,29,1602,29,1607,29,1611,29,1613,29,1616,29,1620,29,1623,29,1626,29,1629,29,1632,29,1634,29,1637,29,1641,29,1644,29,1647,29,1651],{"viewBox":1541,"role":23,"ariaLabelledBy":1542,"xmlns":27,"style":28},"4 0 752 238",[1543,1544],"sk-decide-t","sk-decide-d",[31,1546,1547],{"id":1543},"Run, or skip this time?",[35,1549,1550],{"id":1544},"If size and modified time match the last run, skip without reading the file. If they differ but the data fingerprint matches, skip and store the new metadata. If the data changed, or a run is forced, build and deliver the report and only then store the new fingerprint.",[39,1552],{"x":41,"y":42,"width":43,"height":1553,"fill":45},"238",[39,1555],{"x":1556,"y":57,"width":1557,"height":1558,"rx":60,"fill":61,"stroke":62,"style":63},"167.6","424.8","75.5",[65,1560,1562],{"transform":1561},"translate(368.0,29.0) scale(1.000)",[69,1563],{"d":1564,"fill":72,"stroke":73,"style":74},"M5 9h14 M4 15h14 M10 4l-2 16 M16 4l-2 16",[76,1566,1569],{"x":1567,"y":1558,"style":1568},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Has the input changed since the last successful run?",[47,1571],{"x1":1567,"y1":59,"x2":1567,"y2":1572,"stroke":62,"style":63},"111.5",[47,1574],{"x1":1575,"y1":1572,"x2":1576,"y2":1572,"stroke":62,"style":63},"134.7","625.3",[47,1578],{"x1":1575,"y1":1572,"x2":1575,"y2":1579,"stroke":62,"style":63},"157.5",[1581,1582],"polygon",{"points":1583,"fill":1584},"134.7,161.5 129.7,152.5 139.7,152.5","#5b5cf0",[39,1586],{"x":1587,"y":1588,"width":1589,"height":1590,"rx":1591,"fill":45,"stroke":100,"style":853},"66.7","121.5","135.9","21.0","10",[76,1593,1596],{"x":1575,"y":1594,"style":1595},"136.0","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","size and time match",[39,1598],{"x":56,"y":1599,"width":1600,"height":1601,"rx":60,"fill":99,"stroke":100,"style":63},"163.5","229.3","59.0",[76,1603,1606],{"x":1575,"y":1604,"style":1605},"189.0","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","skip",[76,1608,1610],{"x":1575,"y":1609,"style":903},"208.5","no read at all",[47,1612],{"x1":1567,"y1":1572,"x2":1567,"y2":1579,"stroke":62,"style":63},[1581,1614],{"points":1615,"fill":1584},"380.0,161.5 375.0,152.5 385.0,152.5",[39,1617],{"x":1618,"y":1588,"width":1619,"height":1590,"rx":1591,"fill":45,"stroke":100,"style":853},"330.0","99.9",[76,1621,1622],{"x":1567,"y":1594,"style":1595},"re-saved only",[39,1624],{"x":1625,"y":1599,"width":1600,"height":1601,"rx":60,"fill":125,"stroke":126,"style":63},"265.3",[76,1627,1606],{"x":1567,"y":1604,"style":1628},"font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle",[76,1630,1631],{"x":1567,"y":1609,"style":903},"store new metadata",[47,1633],{"x1":1576,"y1":1572,"x2":1576,"y2":1579,"stroke":62,"style":63},[1581,1635],{"points":1636,"fill":1584},"625.3,161.5 620.3,152.5 630.3,152.5",[39,1638],{"x":1639,"y":1588,"width":1640,"height":1590,"rx":1591,"fill":45,"stroke":100,"style":853},"547.5","155.6",[76,1642,1643],{"x":1576,"y":1594,"style":1595},"data changed or forced",[39,1645],{"x":1646,"y":1599,"width":1600,"height":1601,"rx":60,"fill":149,"stroke":150,"style":63},"510.7",[76,1648,1650],{"x":1576,"y":1604,"style":1649},"font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","run",[76,1652,1653],{"x":1576,"y":1609,"style":903},"record after success",[170,1655,1657],{"id":1656},"step-5-do-not-read-a-file-that-is-still-arriving","Step 5 — Do not read a file that is still arriving",[10,1659,1660,1661,1664,1665,1668],{},"A large export copied over a network share appears before it is complete. Reading it then gives a truncated zip — ",[182,1662,1663],{},"BadZipFile"," — or, worse for this check, a fingerprint of partial data that gets recorded. Wait until the size stops changing, and leave files alone while Excel has them open, which it signals with a ",[182,1666,1667],{},"~$"," lock file beside the workbook:",[175,1670,1672],{"className":207,"code":1671,"language":209,"meta":180,"style":180},"def ready(path, settle=2.0):\n    path = Path(path)\n    if (path.parent \u002F f\"~${path.name}\").exists():\n        return False                                            # open in Excel\n    size = path.stat().st_size\n    time.sleep(settle)\n    return path.stat().st_size == size\n\nprint(ready(\"orders.xlsx\", settle=0.2))\n# True\n",[182,1673,1674,1691,1701,1733,1742,1752,1757,1769,1773,1794],{"__ignoreMap":180},[185,1675,1676,1678,1681,1684,1686,1689],{"class":47,"line":187},[185,1677,477],{"class":216},[185,1679,1680],{"class":480}," ready",[185,1682,1683],{"class":220},"(path, settle",[185,1685,307],{"class":216},[185,1687,1688],{"class":322},"2.0",[185,1690,1391],{"class":220},[185,1692,1693,1696,1698],{"class":47,"line":224},[185,1694,1695],{"class":220},"    path ",[185,1697,307],{"class":216},[185,1699,1700],{"class":220}," Path(path)\n",[185,1702,1703,1705,1708,1711,1714,1717,1721,1724,1727,1730],{"class":47,"line":232},[185,1704,1131],{"class":216},[185,1706,1707],{"class":220}," (path.parent ",[185,1709,1710],{"class":216},"\u002F",[185,1712,1713],{"class":216}," f",[185,1715,1716],{"class":194},"\"~$",[185,1718,1720],{"class":1719},"sSjpA","{",[185,1722,1723],{"class":220},"path.name",[185,1725,1726],{"class":1719},"}",[185,1728,1729],{"class":194},"\"",[185,1731,1732],{"class":220},").exists():\n",[185,1734,1735,1737,1739],{"class":47,"line":240},[185,1736,1163],{"class":216},[185,1738,1166],{"class":322},[185,1740,1741],{"class":545},"                                            # open in Excel\n",[185,1743,1744,1747,1749],{"class":47,"line":248},[185,1745,1746],{"class":220},"    size ",[185,1748,307],{"class":216},[185,1750,1751],{"class":220}," path.stat().st_size\n",[185,1753,1754],{"class":47,"line":262},[185,1755,1756],{"class":220},"    time.sleep(settle)\n",[185,1758,1759,1761,1764,1766],{"class":47,"line":275},[185,1760,489],{"class":216},[185,1762,1763],{"class":220}," path.stat().st_size ",[185,1765,557],{"class":216},[185,1767,1768],{"class":220}," size\n",[185,1770,1771],{"class":47,"line":282},[185,1772,279],{"emptyLinePlaceholder":278},[185,1774,1775,1777,1780,1782,1784,1787,1789,1792],{"class":47,"line":296},[185,1776,551],{"class":322},[185,1778,1779],{"class":220},"(ready(",[185,1781,446],{"class":194},[185,1783,332],{"class":220},[185,1785,1786],{"class":451},"settle",[185,1788,307],{"class":216},[185,1790,1791],{"class":322},"0.2",[185,1793,564],{"class":220},[185,1795,1796],{"class":47,"line":301},[185,1797,751],{"class":545},[10,1799,1800],{},"Use a settle time longer than the gaps in the copy — a few seconds for a local share, longer for slow links. A more reliable fix, when you control the producer, is for it to write under a temporary name and rename the file when done, because a rename is atomic.",[170,1802,1804],{"id":1803},"step-6-notice-when-the-input-stops-changing","Step 6 — Notice when the input stops changing",[10,1806,1807,1808,1810,1811,1814,1815,1818],{},"Skipping is correct for a few runs; skipping for three days on a daily feed means the upstream export has stopped. The ",[182,1809,1650],{}," function already stores ",[182,1812,1813],{},"changed_at"," each time the report actually runs, and ",[182,1816,1817],{},"save_record"," merges records so a skip keeps it. Alert when it is older than a threshold:",[175,1820,1822],{"className":207,"code":1821,"language":209,"meta":180,"style":180},"def stale_for(path, now, limit=timedelta(days=2)):\n    changed_at = load_state().get(str(path), {}).get(\"changed_at\")\n    if changed_at and now - datetime.fromisoformat(changed_at) > limit:\n        return now - datetime.fromisoformat(changed_at)\n    return None\n\nthree_days_on = datetime.now(timezone.utc) + timedelta(days=3)\nprint(stale_for(\"orders.xlsx\", three_days_on) is not None)\n# True\n",[182,1823,1824,1849,1867,1891,1902,1909,1913,1938,1960],{"__ignoreMap":180},[185,1825,1826,1828,1831,1834,1836,1839,1842,1844,1846],{"class":47,"line":187},[185,1827,477],{"class":216},[185,1829,1830],{"class":480}," stale_for",[185,1832,1833],{"class":220},"(path, now, limit",[185,1835,307],{"class":216},[185,1837,1838],{"class":220},"timedelta(",[185,1840,1841],{"class":451},"days",[185,1843,307],{"class":216},[185,1845,409],{"class":322},[185,1847,1848],{"class":220},")):\n",[185,1850,1851,1854,1856,1858,1860,1863,1865],{"class":47,"line":224},[185,1852,1853],{"class":220},"    changed_at ",[185,1855,307],{"class":216},[185,1857,1099],{"class":220},[185,1859,655],{"class":322},[185,1861,1862],{"class":220},"(path), {}).get(",[185,1864,1454],{"class":194},[185,1866,460],{"class":220},[185,1868,1869,1871,1874,1876,1879,1882,1885,1888],{"class":47,"line":232},[185,1870,1131],{"class":216},[185,1872,1873],{"class":220}," changed_at ",[185,1875,1137],{"class":216},[185,1877,1878],{"class":220}," now ",[185,1880,1881],{"class":216},"-",[185,1883,1884],{"class":220}," datetime.fromisoformat(changed_at) ",[185,1886,1887],{"class":216},">",[185,1889,1890],{"class":220}," limit:\n",[185,1892,1893,1895,1897,1899],{"class":47,"line":240},[185,1894,1163],{"class":216},[185,1896,1878],{"class":220},[185,1898,1881],{"class":216},[185,1900,1901],{"class":220}," datetime.fromisoformat(changed_at)\n",[185,1903,1904,1906],{"class":47,"line":248},[185,1905,489],{"class":216},[185,1907,1908],{"class":322}," None\n",[185,1910,1911],{"class":47,"line":262},[185,1912,279],{"emptyLinePlaceholder":278},[185,1914,1915,1918,1920,1923,1926,1929,1931,1933,1936],{"class":47,"line":275},[185,1916,1917],{"class":220},"three_days_on ",[185,1919,307],{"class":216},[185,1921,1922],{"class":220}," datetime.now(timezone.utc) ",[185,1924,1925],{"class":216},"+",[185,1927,1928],{"class":220}," timedelta(",[185,1930,1841],{"class":451},[185,1932,307],{"class":216},[185,1934,1935],{"class":322},"3",[185,1937,460],{"class":220},[185,1939,1940,1942,1945,1947,1950,1953,1955,1958],{"class":47,"line":282},[185,1941,551],{"class":322},[185,1943,1944],{"class":220},"(stale_for(",[185,1946,446],{"class":194},[185,1948,1949],{"class":220},", three_days_on) ",[185,1951,1952],{"class":216},"is",[185,1954,1408],{"class":216},[185,1956,1957],{"class":322}," None",[185,1959,460],{"class":220},[185,1961,1962],{"class":47,"line":296},[185,1963,751],{"class":545},[10,1965,1966,1967,1971],{},"Choose the threshold from the feed's real rhythm: a weekday export needs a limit that survives a weekend, or every Monday starts with a false alarm. A stale input is a failure of something upstream, so route it through the same channel as job failures, described in ",[14,1968,1970],{"href":1969},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails\u002F","Send an Alert When an Excel Report Job Fails",".",[170,1973,1975],{"id":1974},"common-pitfalls","Common pitfalls",[1977,1978,1979,1987,1997,2003,2009],"ul",{},[1980,1981,1982,1986],"li",{},[1983,1984,1985],"strong",{},"Fingerprinting before cleaning."," If the report ignores a column, a change in it should not trigger a rerun. Hash the columns the report reads, not everything.",[1980,1988,1989,1992,1993,1996],{},[1983,1990,1991],{},"One state file for several inputs keyed by name alone."," Key by full path, or two ",[182,1994,1995],{},"orders.xlsx"," files in different folders overwrite each other's record.",[1980,1998,1999,2002],{},[1983,2000,2001],{},"Comparing modified time only."," Some copy tools preserve the source's timestamp, so a new file can arrive with an old time. Size plus time plus the data fingerprint on any difference is the safe combination.",[1980,2004,2005,2008],{},[1983,2006,2007],{},"Deleting the state to force a run."," It also loses the stale-input history. Use the force flag.",[1980,2010,2011,2014],{},[1983,2012,2013],{},"Changing the fingerprint code without a migration."," Hash a different set of columns, or upgrade pandas to a version that hashes a type differently, and every stored fingerprint stops matching: the next run rebuilds everything once. That is harmless for one report and a surprise for fifty — store a version number next to the fingerprints and expect the one-off rerun.",[170,2016,2018],{"id":2017},"conclusion","Conclusion",[10,2020,2021],{},"A workbook's bytes change on every save, so compare the data instead: hash sheet names, columns and values with pandas. Check size and modified time first so untouched files cost nothing, record the fingerprint only after the report is delivered, wait for files that are still arriving, and alert when the input has not changed for longer than it should.",[170,2023,2025],{"id":2024},"frequently-asked-questions","Frequently asked questions",[10,2027,2028,2031],{},[1983,2029,2030],{},"Why does the hash of an Excel file change when the data is the same?","\nEvery save writes a new modified timestamp into the workbook's docProps\u002Fcore.xml, and a different application also rewrites styles and other parts. In a test, the same DataFrame saved twice two seconds apart produced two different file hashes but identical data hashes.",[10,2033,2034,2037],{},[1983,2035,2036],{},"How can I tell if the data in an Excel file changed?","\nRead every sheet with pandas and hash the sheet names, column names and values, for example with pandas.util.hash_pandas_object. That fingerprint stays the same across re-saves and changes when any value changes.",[10,2039,2040,2043],{},[1983,2041,2042],{},"Is checking the file's modified time enough?","\nIt is a good first check, because it costs nothing. If size and modified time match the last run, the file was not touched. If they differ, the file may still hold the same data, so confirm with a content fingerprint before rerunning.",[10,2045,2046,2049],{},[1983,2047,2048],{},"When should the new fingerprint be saved?","\nOnly after the report has been built and delivered. Saving it before means a run that fails halfway marks the input as processed, and the next run skips it.",[170,2051,2053],{"id":2052},"related","Related",[1977,2055,2056,2062,2069,2076],{},[1980,2057,2058,2059,2061],{},"Up one level: ",[14,2060,17],{"href":16}," — making report jobs dependable.",[1980,2063,2064,2068],{},[14,2065,2067],{"href":2066},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a Failed Excel Report Job in Python"," — when the file is late rather than unchanged.",[1980,2070,2071,2075],{},[14,2072,2074],{"href":2073},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fhandle-permission-denied-when-writing-excel-in-python\u002F","Handle Permission Denied When Writing Excel in Python"," — the other side of Excel's file locks.",[1980,2077,2078,2082],{},[14,2079,2081],{"href":2080},"\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"," — checks to run once the report does run.",[2084,2085,2086],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":180,"searchDepth":224,"depth":224,"links":2088},[2089,2090,2091,2092,2093,2094,2095,2096,2097,2098,2099],{"id":172,"depth":224,"text":173},{"id":463,"depth":224,"text":464},{"id":583,"depth":224,"text":584},{"id":995,"depth":224,"text":996},{"id":1221,"depth":224,"text":1222},{"id":1656,"depth":224,"text":1657},{"id":1803,"depth":224,"text":1804},{"id":1974,"depth":224,"text":1975},{"id":2017,"depth":224,"text":2018},{"id":2024,"depth":224,"text":2025},{"id":2052,"depth":224,"text":2053},"2026-09-23","Stop a scheduled Excel report from rebuilding and resending the same numbers — fingerprint the input workbook so re-saves without real changes are ignored, check file metadata first to avoid reading large files, record the fingerprint only after a successful run, skip files that are still being written, and alert when the input goes stale.","md",[2104,2106,2108,2110],{"q":2030,"a":2105},"Every save writes a new modified timestamp into the workbook's docProps\u002Fcore.xml, and a different application also rewrites styles and other parts. In a test, the same DataFrame saved twice two seconds apart produced two different file hashes but identical data hashes.",{"q":2036,"a":2107},"Read every sheet with pandas and hash the sheet names, column names and values, for example with pandas.util.hash_pandas_object. That fingerprint stays the same across re-saves and changes when any value changes.",{"q":2042,"a":2109},"It is a good first check, because it costs nothing. If size and modified time match the last run, the file was not touched. If they differ, the file may still hold the same data, so confirm with a content fingerprint before rerunning.",{"q":2048,"a":2111},"Only after the report has been built and delivered. Saving it before means a run that fails halfway marks the input as processed, and the next run skips it.","skip a report run when the input file has not changed",null,{"breadcrumb":2115},[2116,2118,2121],{"name":2117,"item":1710},"Home",{"name":2119,"item":2120},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed",{"title":2124,"description":2125},"Skip a Python Excel Report When the Input File Is Unchanged","Detect whether an input Excel file really changed before rerunning a report — why the file hash changes on every save, hashing sheet data with pandas instead, a stat fast path, state recorded after success, waiting for files still being copied, Excel lock files and stale-input alerts.","skip-a-report-when-the-input-file-has-not-changed","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed\u002Findex",[2129,2130],"reliable-jobs","scheduling","how-to","DVl0BwL9IT25kC969065NgQ6wJ8KnGG5MShcGo5yJv8",[2134,2138],{"title":2135,"path":2136,"stem":2137,"children":-1},"Set Timeouts on Steps of an Excel Report Job","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fset-timeouts-on-steps-of-an-excel-report-job","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fset-timeouts-on-steps-of-an-excel-report-job\u002Findex",{"title":2139,"path":2140,"stem":2141,"children":-1},"Track Run History and Step Timings of a Report Job","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Ftrack-run-history-and-step-timings-of-a-report-job","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Ftrack-run-history-and-step-timings-of-a-report-job\u002Findex",[2143,2145],{"path":2144,"title":17},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation",{"path":2146,"title":2119},"\u002Fautomating-reporting-workflows",[],[2149,2153,2156,2159,2162,2165],{"path":2150,"title":2151,"kind":2152},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts","Testing and Packaging Excel Automation Scripts","guide",{"path":2154,"title":2155,"kind":2131},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook","Block Large or Sensitive Excel Files with a Pre-commit Hook",{"path":2157,"title":2158,"kind":2131},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse","Build a Command-Line Tool for Excel Reports with argparse",{"path":2160,"title":2161,"kind":2131},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcompare-excel-output-to-a-golden-file-in-tests","Compare Excel Output to a Golden File in Tests",{"path":2163,"title":2164,"kind":2131},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory","Create Excel Test Fixtures in Memory for pytest",{"path":2166,"title":2167,"kind":2131},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver","Diff Excel Files in Git with a textconv Driver",1790972132217]