[{"data":1,"prerenderedAt":2517},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-microsoft-teams-with-python":3,"surround:\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-microsoft-teams-with-python":2509},{"id":4,"title":5,"body":6,"dateModified":2482,"datePublished":2482,"description":2483,"extension":2484,"faq":2485,"meta":2494,"navigation":231,"path":2502,"seo":2503,"slug":2505,"stem":2506,"type":2507,"__hash__":2508},"docs\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-microsoft-teams-with-python\u002Findex.md","Post an Excel Report to Microsoft Teams with Python",{"type":7,"value":8,"toc":2467},"minimark",[9,19,142,147,175,178,199,203,343,346,350,428,961,968,972,975,1109,1117,1121,1128,1425,1428,1432,1435,1644,1655,1659,1662,1948,1951,1955,2062,2066,2069,2126,2129,2133,2217,2224,2373,2383,2387,2396,2400,2407,2413,2419,2425,2429,2463],[10,11,12,13,18],"p",{},"A report that lands in a channel is read; one that lands in an inbox competes with everything else.\nPosting to Teams is a single HTTP request, and the useful version of it does more than announce that\na file exists — it carries the headline numbers, so most people never need to open the workbook. This\nguide is part of\n",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002F","Publishing Excel Reports to Cloud Storage",".",[20,21,29,30,29,34,29,38,29,45,29,55,29,62,29,69,29,74,29,78,29,82,29,87,29,92,29,96,29,99,29,102,29,105,29,114,29,120,29,125,29,130,29,134,29,137],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"0 0 760 201","img",[25,26],"tm-what-t","tm-what-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:760px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[31,32,33],"title",{"id":25},"Announce, or carry the answer",[35,36,37],"desc",{"id":26},"A plain text notification tells people a file exists and they must open it to learn anything, while a card carrying the headline figures answers the question in the channel and links to the detail.",[39,40],"rect",{"x":41,"y":41,"width":42,"height":43,"fill":44},"0","760","201","#ffffff",[39,46],{"x":47,"y":48,"width":49,"height":50,"rx":51,"fill":52,"stroke":53,"style":54},"20","28","270.0","139","14","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[56,57,61],"text",{"x":58,"y":59,"style":60},"155.0","54","font-size:13px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","a text notification",[63,64],"line",{"x1":65,"y1":66,"x2":67,"y2":66,"stroke":53,"style":68},"36","64","274.0","stroke-width:1px",[56,70,73],{"x":58,"y":71,"style":72},"86","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","the file exists",[56,75,77],{"x":58,"y":76,"style":72},"109","open it to learn anything",[56,79,81],{"x":58,"y":80,"style":72},"132","ignored within a week",[39,83],{"x":84,"y":48,"width":49,"height":50,"rx":51,"fill":85,"stroke":86,"style":54},"470.0","#d9f4f1","var(--teal,#0f9488)",[56,88,91],{"x":89,"y":59,"style":90},"605.0","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","a summary card",[63,93],{"x1":94,"y1":66,"x2":95,"y2":66,"stroke":86,"style":68},"486.0","724.0",[56,97,98],{"x":89,"y":71,"style":72},"headline figures inline",[56,100,101],{"x":89,"y":76,"style":72},"link for the detail",[56,103,104],{"x":89,"y":80,"style":72},"read at a glance",[39,106],{"x":107,"y":108,"width":109,"height":110,"rx":111,"fill":112,"stroke":113},"316.0","78.5","128","38","19","#ebebfd","var(--brand,#5b5cf0)",[56,115,119],{"x":116,"y":117,"style":118},"380.0","102.5","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","card",[63,121],{"x1":122,"y1":123,"x2":124,"y2":123,"stroke":113,"style":54},"295.0","97.5","309.0",[126,127],"polygon",{"points":128,"fill":129},"309.0,97.5 300.0,92.5 300.0,102.5","#5b5cf0",[63,131],{"x1":132,"y1":123,"x2":133,"y2":123,"stroke":113,"style":54},"449.0","463.0",[126,135],{"points":136,"fill":129},"463.0,97.5 454.0,92.5 454.0,102.5",[56,138,141],{"x":116,"y":139,"style":140},"187","font-size:12.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle","the link is the fallback, not the message",[143,144,146],"h2",{"id":145},"prerequisites","Prerequisites",[148,149,154],"pre",{"className":150,"code":151,"language":152,"meta":153,"style":153},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install requests pandas\n","bash","",[155,156,157],"code",{"__ignoreMap":153},[158,159,161,165,169,172],"span",{"class":63,"line":160},1,[158,162,164],{"class":163},"sMTad","pip",[158,166,168],{"class":167},"srMev"," install",[158,170,171],{"class":167}," requests",[158,173,174],{"class":167}," pandas\n",[10,176,177],{},"An incoming webhook URL for the target channel, or a Power Automate workflow that accepts an HTTP\nrequest and posts to it. Both take the same Adaptive Card payload. Keep the URL in the environment —\nit is a credential, in the sense that anyone holding it can post to your channel:",[148,179,181],{"className":150,"code":180,"language":152,"meta":153,"style":153},"export TEAMS_WEBHOOK_URL=\"https:\u002F\u002F…\"\n",[155,182,183],{"__ignoreMap":153},[158,184,185,189,193,196],{"class":63,"line":160},[158,186,188],{"class":187},"s-kum","export",[158,190,192],{"class":191},"skGVy"," TEAMS_WEBHOOK_URL",[158,194,195],{"class":187},"=",[158,197,198],{"class":167},"\"https:\u002F\u002F…\"\n",[143,200,202],{"id":201},"the-simplest-possible-post","The simplest possible post",[148,204,208],{"className":205,"code":206,"language":207,"meta":153,"style":153},"language-python shiki shiki-themes github-light github-dark-high-contrast","import os\nimport requests\n\ndef notify(text: str) -> None:\n    response = requests.post(\n        os.environ[\"TEAMS_WEBHOOK_URL\"],\n        json={\"text\": text},\n        timeout=30,\n    )\n    response.raise_for_status()\n\nnotify(\"Regional revenue report for 2026-09-01 is ready.\")\n","python",[155,209,210,218,226,233,259,270,282,300,314,320,326,331],{"__ignoreMap":153},[158,211,212,215],{"class":63,"line":160},[158,213,214],{"class":187},"import",[158,216,217],{"class":191}," os\n",[158,219,221,223],{"class":63,"line":220},2,[158,222,214],{"class":187},[158,224,225],{"class":191}," requests\n",[158,227,229],{"class":63,"line":228},3,[158,230,232],{"emptyLinePlaceholder":231},true,"\n",[158,234,236,239,243,246,250,253,256],{"class":63,"line":235},4,[158,237,238],{"class":187},"def",[158,240,242],{"class":241},"s_Opv"," notify",[158,244,245],{"class":191},"(text: ",[158,247,249],{"class":248},"sP0c6","str",[158,251,252],{"class":191},") -> ",[158,254,255],{"class":248},"None",[158,257,258],{"class":191},":\n",[158,260,262,265,267],{"class":63,"line":261},5,[158,263,264],{"class":191},"    response ",[158,266,195],{"class":187},[158,268,269],{"class":191}," requests.post(\n",[158,271,273,276,279],{"class":63,"line":272},6,[158,274,275],{"class":191},"        os.environ[",[158,277,278],{"class":167},"\"TEAMS_WEBHOOK_URL\"",[158,280,281],{"class":191},"],\n",[158,283,285,289,291,294,297],{"class":63,"line":284},7,[158,286,288],{"class":287},"sa561","        json",[158,290,195],{"class":187},[158,292,293],{"class":191},"{",[158,295,296],{"class":167},"\"text\"",[158,298,299],{"class":191},": text},\n",[158,301,303,306,308,311],{"class":63,"line":302},8,[158,304,305],{"class":287},"        timeout",[158,307,195],{"class":187},[158,309,310],{"class":248},"30",[158,312,313],{"class":191},",\n",[158,315,317],{"class":63,"line":316},9,[158,318,319],{"class":191},"    )\n",[158,321,323],{"class":63,"line":322},10,[158,324,325],{"class":191},"    response.raise_for_status()\n",[158,327,329],{"class":63,"line":328},11,[158,330,232],{"emptyLinePlaceholder":231},[158,332,334,337,340],{"class":63,"line":333},12,[158,335,336],{"class":191},"notify(",[158,338,339],{"class":167},"\"Regional revenue report for 2026-09-01 is ready.\"",[158,341,342],{"class":191},")\n",[10,344,345],{},"Useful for a first test and inadequate as a deliverable. What a channel actually wants is the numbers\nand a link, which means an Adaptive Card.",[143,347,349],{"id":348},"an-adaptive-card-carrying-the-reports-figures","An Adaptive Card carrying the report's figures",[20,351,29,356,29,359,29,362,29,365,29,370,29,378,29,384,29,389,29,394,29,397,29,400,29,403,29,406,29,410,29,413,29,416,29,421,29,424],{"viewBox":352,"role":23,"ariaLabelledBy":353,"xmlns":27,"style":28},"0 0 760 232",[354,355],"tm-card-t","tm-card-d",[31,357,358],{"id":354},"What the job posts, and where the file lives",[35,360,361],{"id":355},"The workbook is uploaded once to the channel's own document library, and the card posted to the channel carries the summary numbers plus a link back to that single copy.",[39,363],{"x":41,"y":41,"width":42,"height":364,"fill":44},"232",[56,366,369],{"x":116,"y":367,"style":368},"32","font-size:13px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","publish then announce",[39,371],{"x":372,"y":373,"width":374,"height":375,"rx":376,"fill":377,"stroke":113,"style":54},"24.0","74","208.0","96","12","#f0f4ff",[56,379,383],{"x":380,"y":381,"style":382},"128.0","114","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","build the workbook",[56,385,388],{"x":380,"y":386,"style":387},"136","font-size:11.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle","one artefact",[63,390],{"x1":391,"y1":392,"x2":393,"y2":392,"stroke":113,"style":54},"237.0","122.0","269.0",[126,395],{"points":396,"fill":129},"269.0,122.0 260.0,117.0 260.0,127.0",[39,398],{"x":399,"y":373,"width":374,"height":375,"rx":376,"fill":377,"stroke":113,"style":54},"276.0",[56,401,402],{"x":116,"y":381,"style":382},"upload to the library",[56,404,405],{"x":116,"y":386,"style":387},"one copy, inherited access",[63,407],{"x1":408,"y1":392,"x2":409,"y2":392,"stroke":113,"style":54},"489.0","521.0",[126,411],{"points":412,"fill":129},"521.0,122.0 512.0,117.0 512.0,127.0",[39,414],{"x":415,"y":373,"width":374,"height":375,"rx":376,"fill":85,"stroke":86,"style":54},"528.0",[56,417,420],{"x":418,"y":381,"style":419},"632.0","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","post the card",[56,422,423],{"x":418,"y":386,"style":387},"figures plus a link",[56,425,427],{"x":116,"y":426,"style":140},"210","one file, one link — never a copy per recipient",[148,429,431],{"className":205,"code":430,"language":207,"meta":153,"style":153},"import os\nimport requests\nimport pandas as pd\n\ndef report_card(summary: pd.DataFrame, run_date: str, link: str) -> dict:\n    total = summary[\"Revenue\"].sum()\n    facts = [\n        {\"title\": row.Region, \"value\": f\"{row.Revenue:,.0f}\"}\n        for row in summary.itertuples(index=False)\n    ]\n    return {\n        \"type\": \"message\",\n        \"attachments\": [{\n            \"contentType\": \"application\u002Fvnd.microsoft.card.adaptive\",\n            \"content\": {\n                \"$schema\": \"http:\u002F\u002Fadaptivecards.io\u002Fschemas\u002Fadaptive-card.json\",\n                \"type\": \"AdaptiveCard\",\n                \"version\": \"1.5\",\n                \"body\": [\n                    {\"type\": \"TextBlock\", \"size\": \"Large\", \"weight\": \"Bolder\",\n                     \"text\": f\"Regional revenue — {run_date}\"},\n                    {\"type\": \"TextBlock\", \"spacing\": \"None\", \"isSubtle\": True,\n                     \"text\": f\"Total {total:,.0f} across {len(summary)} regions\"},\n                    {\"type\": \"FactSet\", \"facts\": facts},\n                ],\n                \"actions\": [\n                    {\"type\": \"Action.OpenUrl\", \"title\": \"Open the workbook\", \"url\": link},\n                ],\n            },\n        }],\n    }\n\ndef post_card(card: dict) -> None:\n    response = requests.post(os.environ[\"TEAMS_WEBHOOK_URL\"], json=card, timeout=30)\n    response.raise_for_status()\n",[155,432,433,439,445,458,462,486,502,512,552,576,581,589,601,610,623,632,645,658,671,680,717,742,775,814,834,840,848,877,882,888,894,900,905,924,956],{"__ignoreMap":153},[158,434,435,437],{"class":63,"line":160},[158,436,214],{"class":187},[158,438,217],{"class":191},[158,440,441,443],{"class":63,"line":220},[158,442,214],{"class":187},[158,444,225],{"class":191},[158,446,447,449,452,455],{"class":63,"line":228},[158,448,214],{"class":187},[158,450,451],{"class":191}," pandas ",[158,453,454],{"class":187},"as",[158,456,457],{"class":191}," pd\n",[158,459,460],{"class":63,"line":235},[158,461,232],{"emptyLinePlaceholder":231},[158,463,464,466,469,472,474,477,479,481,484],{"class":63,"line":261},[158,465,238],{"class":187},[158,467,468],{"class":241}," report_card",[158,470,471],{"class":191},"(summary: pd.DataFrame, run_date: ",[158,473,249],{"class":248},[158,475,476],{"class":191},", link: ",[158,478,249],{"class":248},[158,480,252],{"class":191},[158,482,483],{"class":248},"dict",[158,485,258],{"class":191},[158,487,488,491,493,496,499],{"class":63,"line":272},[158,489,490],{"class":191},"    total ",[158,492,195],{"class":187},[158,494,495],{"class":191}," summary[",[158,497,498],{"class":167},"\"Revenue\"",[158,500,501],{"class":191},"].sum()\n",[158,503,504,507,509],{"class":63,"line":284},[158,505,506],{"class":191},"    facts ",[158,508,195],{"class":187},[158,510,511],{"class":191}," [\n",[158,513,514,517,520,523,526,529,532,535,538,541,544,547,549],{"class":63,"line":302},[158,515,516],{"class":191},"        {",[158,518,519],{"class":167},"\"title\"",[158,521,522],{"class":191},": row.Region, ",[158,524,525],{"class":167},"\"value\"",[158,527,528],{"class":191},": ",[158,530,531],{"class":187},"f",[158,533,534],{"class":167},"\"",[158,536,293],{"class":537},"sSjpA",[158,539,540],{"class":191},"row.Revenue",[158,542,543],{"class":187},":,.0f",[158,545,546],{"class":537},"}",[158,548,534],{"class":167},[158,550,551],{"class":191},"}\n",[158,553,554,557,560,563,566,569,571,574],{"class":63,"line":316},[158,555,556],{"class":187},"        for",[158,558,559],{"class":191}," row ",[158,561,562],{"class":187},"in",[158,564,565],{"class":191}," summary.itertuples(",[158,567,568],{"class":287},"index",[158,570,195],{"class":187},[158,572,573],{"class":248},"False",[158,575,342],{"class":191},[158,577,578],{"class":63,"line":322},[158,579,580],{"class":191},"    ]\n",[158,582,583,586],{"class":63,"line":328},[158,584,585],{"class":187},"    return",[158,587,588],{"class":191}," {\n",[158,590,591,594,596,599],{"class":63,"line":333},[158,592,593],{"class":167},"        \"type\"",[158,595,528],{"class":191},[158,597,598],{"class":167},"\"message\"",[158,600,313],{"class":191},[158,602,604,607],{"class":63,"line":603},13,[158,605,606],{"class":167},"        \"attachments\"",[158,608,609],{"class":191},": [{\n",[158,611,613,616,618,621],{"class":63,"line":612},14,[158,614,615],{"class":167},"            \"contentType\"",[158,617,528],{"class":191},[158,619,620],{"class":167},"\"application\u002Fvnd.microsoft.card.adaptive\"",[158,622,313],{"class":191},[158,624,626,629],{"class":63,"line":625},15,[158,627,628],{"class":167},"            \"content\"",[158,630,631],{"class":191},": {\n",[158,633,635,638,640,643],{"class":63,"line":634},16,[158,636,637],{"class":167},"                \"$schema\"",[158,639,528],{"class":191},[158,641,642],{"class":167},"\"http:\u002F\u002Fadaptivecards.io\u002Fschemas\u002Fadaptive-card.json\"",[158,644,313],{"class":191},[158,646,648,651,653,656],{"class":63,"line":647},17,[158,649,650],{"class":167},"                \"type\"",[158,652,528],{"class":191},[158,654,655],{"class":167},"\"AdaptiveCard\"",[158,657,313],{"class":191},[158,659,661,664,666,669],{"class":63,"line":660},18,[158,662,663],{"class":167},"                \"version\"",[158,665,528],{"class":191},[158,667,668],{"class":167},"\"1.5\"",[158,670,313],{"class":191},[158,672,674,677],{"class":63,"line":673},19,[158,675,676],{"class":167},"                \"body\"",[158,678,679],{"class":191},": [\n",[158,681,683,686,689,691,694,697,700,702,705,707,710,712,715],{"class":63,"line":682},20,[158,684,685],{"class":191},"                    {",[158,687,688],{"class":167},"\"type\"",[158,690,528],{"class":191},[158,692,693],{"class":167},"\"TextBlock\"",[158,695,696],{"class":191},", ",[158,698,699],{"class":167},"\"size\"",[158,701,528],{"class":191},[158,703,704],{"class":167},"\"Large\"",[158,706,696],{"class":191},[158,708,709],{"class":167},"\"weight\"",[158,711,528],{"class":191},[158,713,714],{"class":167},"\"Bolder\"",[158,716,313],{"class":191},[158,718,720,723,725,727,730,732,735,737,739],{"class":63,"line":719},21,[158,721,722],{"class":167},"                     \"text\"",[158,724,528],{"class":191},[158,726,531],{"class":187},[158,728,729],{"class":167},"\"Regional revenue — ",[158,731,293],{"class":537},[158,733,734],{"class":191},"run_date",[158,736,546],{"class":537},[158,738,534],{"class":167},[158,740,741],{"class":191},"},\n",[158,743,745,747,749,751,753,755,758,760,763,765,768,770,773],{"class":63,"line":744},22,[158,746,685],{"class":191},[158,748,688],{"class":167},[158,750,528],{"class":191},[158,752,693],{"class":167},[158,754,696],{"class":191},[158,756,757],{"class":167},"\"spacing\"",[158,759,528],{"class":191},[158,761,762],{"class":167},"\"None\"",[158,764,696],{"class":191},[158,766,767],{"class":167},"\"isSubtle\"",[158,769,528],{"class":191},[158,771,772],{"class":248},"True",[158,774,313],{"class":191},[158,776,778,780,782,784,787,789,792,794,796,799,801,804,807,809,812],{"class":63,"line":777},23,[158,779,722],{"class":167},[158,781,528],{"class":191},[158,783,531],{"class":187},[158,785,786],{"class":167},"\"Total ",[158,788,293],{"class":537},[158,790,791],{"class":191},"total",[158,793,543],{"class":187},[158,795,546],{"class":537},[158,797,798],{"class":167}," across ",[158,800,293],{"class":537},[158,802,803],{"class":248},"len",[158,805,806],{"class":191},"(summary)",[158,808,546],{"class":537},[158,810,811],{"class":167}," regions\"",[158,813,741],{"class":191},[158,815,817,819,821,823,826,828,831],{"class":63,"line":816},24,[158,818,685],{"class":191},[158,820,688],{"class":167},[158,822,528],{"class":191},[158,824,825],{"class":167},"\"FactSet\"",[158,827,696],{"class":191},[158,829,830],{"class":167},"\"facts\"",[158,832,833],{"class":191},": facts},\n",[158,835,837],{"class":63,"line":836},25,[158,838,839],{"class":191},"                ],\n",[158,841,843,846],{"class":63,"line":842},26,[158,844,845],{"class":167},"                \"actions\"",[158,847,679],{"class":191},[158,849,851,853,855,857,860,862,864,866,869,871,874],{"class":63,"line":850},27,[158,852,685],{"class":191},[158,854,688],{"class":167},[158,856,528],{"class":191},[158,858,859],{"class":167},"\"Action.OpenUrl\"",[158,861,696],{"class":191},[158,863,519],{"class":167},[158,865,528],{"class":191},[158,867,868],{"class":167},"\"Open the workbook\"",[158,870,696],{"class":191},[158,872,873],{"class":167},"\"url\"",[158,875,876],{"class":191},": link},\n",[158,878,880],{"class":63,"line":879},28,[158,881,839],{"class":191},[158,883,885],{"class":63,"line":884},29,[158,886,887],{"class":191},"            },\n",[158,889,891],{"class":63,"line":890},30,[158,892,893],{"class":191},"        }],\n",[158,895,897],{"class":63,"line":896},31,[158,898,899],{"class":191},"    }\n",[158,901,903],{"class":63,"line":902},32,[158,904,232],{"emptyLinePlaceholder":231},[158,906,908,910,913,916,918,920,922],{"class":63,"line":907},33,[158,909,238],{"class":187},[158,911,912],{"class":241}," post_card",[158,914,915],{"class":191},"(card: ",[158,917,483],{"class":248},[158,919,252],{"class":191},[158,921,255],{"class":248},[158,923,258],{"class":191},[158,925,927,929,931,934,936,939,942,944,947,950,952,954],{"class":63,"line":926},34,[158,928,264],{"class":191},[158,930,195],{"class":187},[158,932,933],{"class":191}," requests.post(os.environ[",[158,935,278],{"class":167},[158,937,938],{"class":191},"], ",[158,940,941],{"class":287},"json",[158,943,195],{"class":187},[158,945,946],{"class":191},"card, ",[158,948,949],{"class":287},"timeout",[158,951,195],{"class":187},[158,953,310],{"class":248},[158,955,342],{"class":191},[158,957,959],{"class":63,"line":958},35,[158,960,325],{"class":191},[10,962,963,964,967],{},"A ",[155,965,966],{},"FactSet"," renders as an aligned label-and-value list, which is exactly the shape of a small\nsummary. Keeping it to a handful of rows is deliberate — a card is a notification, and a card that\ntries to be the report defeats the purpose of the link beneath it.",[143,969,971],{"id":970},"getting-a-link-people-can-actually-open","Getting a link people can actually open",[10,973,974],{},"The card needs a URL, and which one depends on where the file lives. A SharePoint or OneDrive link is\nbest for a Teams audience because permissions are already understood; a presigned S3 URL works but\nexpires and bypasses the organisation's access controls.",[148,976,978],{"className":205,"code":977,"language":207,"meta":153,"style":153},"from datetime import timedelta\n\n# SharePoint \u002F OneDrive: a stable link the channel members already have access to\nlink = \"https:\u002F\u002Fcontoso.sharepoint.com\u002Fsites\u002FFinance\u002FShared%20Documents\u002Fregional-2026-09-01.xlsx\"\n\n# S3 alternative, if that is where reports live\nimport boto3\npresigned = boto3.client(\"s3\").generate_presigned_url(\n    \"get_object\",\n    Params={\"Bucket\": \"reporting-artifacts\", \"Key\": \"reports\u002Fregional-2026-09-01.xlsx\"},\n    ExpiresIn=int(timedelta(days=7).total_seconds()),\n)\n",[155,979,980,993,997,1003,1013,1017,1022,1029,1045,1052,1081,1105],{"__ignoreMap":153},[158,981,982,985,988,990],{"class":63,"line":160},[158,983,984],{"class":187},"from",[158,986,987],{"class":191}," datetime ",[158,989,214],{"class":187},[158,991,992],{"class":191}," timedelta\n",[158,994,995],{"class":63,"line":220},[158,996,232],{"emptyLinePlaceholder":231},[158,998,999],{"class":63,"line":228},[158,1000,1002],{"class":1001},"s-wDw","# SharePoint \u002F OneDrive: a stable link the channel members already have access to\n",[158,1004,1005,1008,1010],{"class":63,"line":235},[158,1006,1007],{"class":191},"link ",[158,1009,195],{"class":187},[158,1011,1012],{"class":167}," \"https:\u002F\u002Fcontoso.sharepoint.com\u002Fsites\u002FFinance\u002FShared%20Documents\u002Fregional-2026-09-01.xlsx\"\n",[158,1014,1015],{"class":63,"line":261},[158,1016,232],{"emptyLinePlaceholder":231},[158,1018,1019],{"class":63,"line":272},[158,1020,1021],{"class":1001},"# S3 alternative, if that is where reports live\n",[158,1023,1024,1026],{"class":63,"line":284},[158,1025,214],{"class":187},[158,1027,1028],{"class":191}," boto3\n",[158,1030,1031,1034,1036,1039,1042],{"class":63,"line":302},[158,1032,1033],{"class":191},"presigned ",[158,1035,195],{"class":187},[158,1037,1038],{"class":191}," boto3.client(",[158,1040,1041],{"class":167},"\"s3\"",[158,1043,1044],{"class":191},").generate_presigned_url(\n",[158,1046,1047,1050],{"class":63,"line":316},[158,1048,1049],{"class":167},"    \"get_object\"",[158,1051,313],{"class":191},[158,1053,1054,1057,1059,1061,1064,1066,1069,1071,1074,1076,1079],{"class":63,"line":322},[158,1055,1056],{"class":287},"    Params",[158,1058,195],{"class":187},[158,1060,293],{"class":191},[158,1062,1063],{"class":167},"\"Bucket\"",[158,1065,528],{"class":191},[158,1067,1068],{"class":167},"\"reporting-artifacts\"",[158,1070,696],{"class":191},[158,1072,1073],{"class":167},"\"Key\"",[158,1075,528],{"class":191},[158,1077,1078],{"class":167},"\"reports\u002Fregional-2026-09-01.xlsx\"",[158,1080,741],{"class":191},[158,1082,1083,1086,1088,1091,1094,1097,1099,1102],{"class":63,"line":328},[158,1084,1085],{"class":287},"    ExpiresIn",[158,1087,195],{"class":187},[158,1089,1090],{"class":248},"int",[158,1092,1093],{"class":191},"(timedelta(",[158,1095,1096],{"class":287},"days",[158,1098,195],{"class":187},[158,1100,1101],{"class":248},"7",[158,1103,1104],{"class":191},").total_seconds()),\n",[158,1106,1107],{"class":63,"line":333},[158,1108,342],{"class":191},[10,1110,1111,1112,1116],{},"Uploading to the channel's own document library is covered in\n",[14,1113,1115],{"href":1114},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-to-sharepoint-with-python\u002F","Upload an Excel Report to SharePoint with Python",",\nand it is the arrangement worth preferring: one copy, inherited permissions, and a link that does not\nexpire in the middle of a quarter.",[143,1118,1120],{"id":1119},"colouring-the-card-by-outcome","Colouring the card by outcome",[10,1122,1123,1124,1127],{},"A card that looks the same whether the report succeeded or failed trains people to ignore it. An\n",[155,1125,1126],{},"Container"," with a style, or a leading emoji, makes the state readable at a glance.",[148,1129,1131],{"className":205,"code":1130,"language":207,"meta":153,"style":153},"def status_card(title: str, detail: str, ok: bool) -> dict:\n    return {\n        \"type\": \"message\",\n        \"attachments\": [{\n            \"contentType\": \"application\u002Fvnd.microsoft.card.adaptive\",\n            \"content\": {\n                \"$schema\": \"http:\u002F\u002Fadaptivecards.io\u002Fschemas\u002Fadaptive-card.json\",\n                \"type\": \"AdaptiveCard\", \"version\": \"1.5\",\n                \"body\": [{\n                    \"type\": \"Container\",\n                    \"style\": \"good\" if ok else \"attention\",\n                    \"bleed\": True,\n                    \"items\": [\n                        {\"type\": \"TextBlock\", \"weight\": \"Bolder\",\n                         \"text\": f\"{'✅' if ok else '⚠️'} {title}\"},\n                        {\"type\": \"TextBlock\", \"text\": detail, \"wrap\": True},\n                    ],\n                }],\n            },\n        }],\n    }\n\npost_card(status_card(\"Regional revenue\", \"3 of 4 regions reported; West is missing.\", ok=False))\n",[155,1132,1133,1162,1168,1178,1184,1194,1200,1210,1229,1235,1247,1271,1282,1289,1310,1348,1374,1379,1384,1388,1392,1396,1400],{"__ignoreMap":153},[158,1134,1135,1137,1140,1143,1145,1148,1150,1153,1156,1158,1160],{"class":63,"line":160},[158,1136,238],{"class":187},[158,1138,1139],{"class":241}," status_card",[158,1141,1142],{"class":191},"(title: ",[158,1144,249],{"class":248},[158,1146,1147],{"class":191},", detail: ",[158,1149,249],{"class":248},[158,1151,1152],{"class":191},", ok: ",[158,1154,1155],{"class":248},"bool",[158,1157,252],{"class":191},[158,1159,483],{"class":248},[158,1161,258],{"class":191},[158,1163,1164,1166],{"class":63,"line":220},[158,1165,585],{"class":187},[158,1167,588],{"class":191},[158,1169,1170,1172,1174,1176],{"class":63,"line":228},[158,1171,593],{"class":167},[158,1173,528],{"class":191},[158,1175,598],{"class":167},[158,1177,313],{"class":191},[158,1179,1180,1182],{"class":63,"line":235},[158,1181,606],{"class":167},[158,1183,609],{"class":191},[158,1185,1186,1188,1190,1192],{"class":63,"line":261},[158,1187,615],{"class":167},[158,1189,528],{"class":191},[158,1191,620],{"class":167},[158,1193,313],{"class":191},[158,1195,1196,1198],{"class":63,"line":272},[158,1197,628],{"class":167},[158,1199,631],{"class":191},[158,1201,1202,1204,1206,1208],{"class":63,"line":284},[158,1203,637],{"class":167},[158,1205,528],{"class":191},[158,1207,642],{"class":167},[158,1209,313],{"class":191},[158,1211,1212,1214,1216,1218,1220,1223,1225,1227],{"class":63,"line":302},[158,1213,650],{"class":167},[158,1215,528],{"class":191},[158,1217,655],{"class":167},[158,1219,696],{"class":191},[158,1221,1222],{"class":167},"\"version\"",[158,1224,528],{"class":191},[158,1226,668],{"class":167},[158,1228,313],{"class":191},[158,1230,1231,1233],{"class":63,"line":316},[158,1232,676],{"class":167},[158,1234,609],{"class":191},[158,1236,1237,1240,1242,1245],{"class":63,"line":322},[158,1238,1239],{"class":167},"                    \"type\"",[158,1241,528],{"class":191},[158,1243,1244],{"class":167},"\"Container\"",[158,1246,313],{"class":191},[158,1248,1249,1252,1254,1257,1260,1263,1266,1269],{"class":63,"line":328},[158,1250,1251],{"class":167},"                    \"style\"",[158,1253,528],{"class":191},[158,1255,1256],{"class":167},"\"good\"",[158,1258,1259],{"class":187}," if",[158,1261,1262],{"class":191}," ok ",[158,1264,1265],{"class":187},"else",[158,1267,1268],{"class":167}," \"attention\"",[158,1270,313],{"class":191},[158,1272,1273,1276,1278,1280],{"class":63,"line":333},[158,1274,1275],{"class":167},"                    \"bleed\"",[158,1277,528],{"class":191},[158,1279,772],{"class":248},[158,1281,313],{"class":191},[158,1283,1284,1287],{"class":63,"line":603},[158,1285,1286],{"class":167},"                    \"items\"",[158,1288,679],{"class":191},[158,1290,1291,1294,1296,1298,1300,1302,1304,1306,1308],{"class":63,"line":612},[158,1292,1293],{"class":191},"                        {",[158,1295,688],{"class":167},[158,1297,528],{"class":191},[158,1299,693],{"class":167},[158,1301,696],{"class":191},[158,1303,709],{"class":167},[158,1305,528],{"class":191},[158,1307,714],{"class":167},[158,1309,313],{"class":191},[158,1311,1312,1315,1317,1319,1321,1323,1326,1328,1330,1332,1335,1337,1340,1342,1344,1346],{"class":63,"line":625},[158,1313,1314],{"class":167},"                         \"text\"",[158,1316,528],{"class":191},[158,1318,531],{"class":187},[158,1320,534],{"class":167},[158,1322,293],{"class":537},[158,1324,1325],{"class":167},"'✅'",[158,1327,1259],{"class":187},[158,1329,1262],{"class":191},[158,1331,1265],{"class":187},[158,1333,1334],{"class":167}," '⚠️'",[158,1336,546],{"class":537},[158,1338,1339],{"class":537}," {",[158,1341,31],{"class":191},[158,1343,546],{"class":537},[158,1345,534],{"class":167},[158,1347,741],{"class":191},[158,1349,1350,1352,1354,1356,1358,1360,1362,1365,1368,1370,1372],{"class":63,"line":634},[158,1351,1293],{"class":191},[158,1353,688],{"class":167},[158,1355,528],{"class":191},[158,1357,693],{"class":167},[158,1359,696],{"class":191},[158,1361,296],{"class":167},[158,1363,1364],{"class":191},": detail, ",[158,1366,1367],{"class":167},"\"wrap\"",[158,1369,528],{"class":191},[158,1371,772],{"class":248},[158,1373,741],{"class":191},[158,1375,1376],{"class":63,"line":647},[158,1377,1378],{"class":191},"                    ],\n",[158,1380,1381],{"class":63,"line":660},[158,1382,1383],{"class":191},"                }],\n",[158,1385,1386],{"class":63,"line":673},[158,1387,887],{"class":191},[158,1389,1390],{"class":63,"line":682},[158,1391,893],{"class":191},[158,1393,1394],{"class":63,"line":719},[158,1395,899],{"class":191},[158,1397,1398],{"class":63,"line":744},[158,1399,232],{"emptyLinePlaceholder":231},[158,1401,1402,1405,1408,1410,1413,1415,1418,1420,1422],{"class":63,"line":777},[158,1403,1404],{"class":191},"post_card(status_card(",[158,1406,1407],{"class":167},"\"Regional revenue\"",[158,1409,696],{"class":191},[158,1411,1412],{"class":167},"\"3 of 4 regions reported; West is missing.\"",[158,1414,696],{"class":191},[158,1416,1417],{"class":287},"ok",[158,1419,195],{"class":187},[158,1421,573],{"class":248},[158,1423,1424],{"class":191},"))\n",[10,1426,1427],{},"Sending the failure card from the job's exception handler is what makes this worth building — a\nreport that quietly does not run is the failure mode a channel post is best at catching.",[143,1429,1431],{"id":1430},"building-the-card-from-the-report-not-by-hand","Building the card from the report, not by hand",[10,1433,1434],{},"The cards that stay useful are the ones generated from the same frame that produced the workbook, so\nthat they cannot drift out of step with it. Writing one small function that turns a summary frame\ninto card body elements keeps that guarantee.",[148,1436,1438],{"className":205,"code":1437,"language":207,"meta":153,"style":153},"def facts_from(frame: pd.DataFrame, label: str, value: str, limit: int = 6) -> list[dict]:\n    top = frame.nlargest(limit, value)\n    facts = [{\"title\": str(row[label]), \"value\": f\"{row[value]:,.0f}\"}\n             for _, row in top.iterrows()]\n    remainder = len(frame) - len(top)\n    if remainder > 0:\n        others = frame[value].sum() - top[value].sum()\n        facts.append({\"title\": f\"{remainder} others\", \"value\": f\"{others:,.0f}\"})\n    return facts\n",[155,1439,1440,1476,1486,1525,1538,1559,1575,1590,1637],{"__ignoreMap":153},[158,1441,1442,1444,1447,1450,1452,1455,1457,1460,1462,1465,1468,1471,1473],{"class":63,"line":160},[158,1443,238],{"class":187},[158,1445,1446],{"class":241}," facts_from",[158,1448,1449],{"class":191},"(frame: pd.DataFrame, label: ",[158,1451,249],{"class":248},[158,1453,1454],{"class":191},", value: ",[158,1456,249],{"class":248},[158,1458,1459],{"class":191},", limit: ",[158,1461,1090],{"class":248},[158,1463,1464],{"class":187}," =",[158,1466,1467],{"class":248}," 6",[158,1469,1470],{"class":191},") -> list[",[158,1472,483],{"class":248},[158,1474,1475],{"class":191},"]:\n",[158,1477,1478,1481,1483],{"class":63,"line":220},[158,1479,1480],{"class":191},"    top ",[158,1482,195],{"class":187},[158,1484,1485],{"class":191}," frame.nlargest(limit, value)\n",[158,1487,1488,1490,1492,1495,1497,1499,1501,1504,1506,1508,1510,1512,1514,1517,1519,1521,1523],{"class":63,"line":228},[158,1489,506],{"class":191},[158,1491,195],{"class":187},[158,1493,1494],{"class":191}," [{",[158,1496,519],{"class":167},[158,1498,528],{"class":191},[158,1500,249],{"class":248},[158,1502,1503],{"class":191},"(row[label]), ",[158,1505,525],{"class":167},[158,1507,528],{"class":191},[158,1509,531],{"class":187},[158,1511,534],{"class":167},[158,1513,293],{"class":537},[158,1515,1516],{"class":191},"row[value]",[158,1518,543],{"class":187},[158,1520,546],{"class":537},[158,1522,534],{"class":167},[158,1524,551],{"class":191},[158,1526,1527,1530,1533,1535],{"class":63,"line":235},[158,1528,1529],{"class":187},"             for",[158,1531,1532],{"class":191}," _, row ",[158,1534,562],{"class":187},[158,1536,1537],{"class":191}," top.iterrows()]\n",[158,1539,1540,1543,1545,1548,1551,1554,1556],{"class":63,"line":261},[158,1541,1542],{"class":191},"    remainder ",[158,1544,195],{"class":187},[158,1546,1547],{"class":248}," len",[158,1549,1550],{"class":191},"(frame) ",[158,1552,1553],{"class":187},"-",[158,1555,1547],{"class":248},[158,1557,1558],{"class":191},"(top)\n",[158,1560,1561,1564,1567,1570,1573],{"class":63,"line":272},[158,1562,1563],{"class":187},"    if",[158,1565,1566],{"class":191}," remainder ",[158,1568,1569],{"class":187},">",[158,1571,1572],{"class":248}," 0",[158,1574,258],{"class":191},[158,1576,1577,1580,1582,1585,1587],{"class":63,"line":284},[158,1578,1579],{"class":191},"        others ",[158,1581,195],{"class":187},[158,1583,1584],{"class":191}," frame[value].sum() ",[158,1586,1553],{"class":187},[158,1588,1589],{"class":191}," top[value].sum()\n",[158,1591,1592,1595,1597,1599,1601,1603,1605,1608,1610,1613,1615,1617,1619,1621,1623,1625,1628,1630,1632,1634],{"class":63,"line":302},[158,1593,1594],{"class":191},"        facts.append({",[158,1596,519],{"class":167},[158,1598,528],{"class":191},[158,1600,531],{"class":187},[158,1602,534],{"class":167},[158,1604,293],{"class":537},[158,1606,1607],{"class":191},"remainder",[158,1609,546],{"class":537},[158,1611,1612],{"class":167}," others\"",[158,1614,696],{"class":191},[158,1616,525],{"class":167},[158,1618,528],{"class":191},[158,1620,531],{"class":187},[158,1622,534],{"class":167},[158,1624,293],{"class":537},[158,1626,1627],{"class":191},"others",[158,1629,543],{"class":187},[158,1631,546],{"class":537},[158,1633,534],{"class":167},[158,1635,1636],{"class":191},"})\n",[158,1638,1639,1641],{"class":63,"line":316},[158,1640,585],{"class":187},[158,1642,1643],{"class":191}," facts\n",[10,1645,1646,1647,1650,1651,18],{},"Collapsing everything past the top six into an \"others\" row is what keeps a card readable when the\nnumber of regions grows from four to forty — and it keeps the total honest, which a simple truncation\nwould not. The ranking behind it is the same ",[155,1648,1649],{},"nlargest"," described in\n",[14,1652,1654],{"href":1653},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Frank-and-percentile-formulas-in-pandas\u002F","RANK and PERCENTILE Formulas in pandas",[143,1656,1658],{"id":1657},"showing-movement-not-just-levels","Showing movement, not just levels",[10,1660,1661],{},"A number in a channel is much more useful with a comparison beside it. Adding the change against the\nprevious run turns the card from a statement into something a reader can react to.",[148,1663,1665],{"className":205,"code":1664,"language":207,"meta":153,"style":153},"def facts_with_change(current: pd.DataFrame, prior: pd.DataFrame, key: str, value: str) -> list[dict]:\n    merged = current.merge(prior, on=key, how=\"left\", suffixes=(\"\", \"_prior\"))\n    facts = []\n    for _, row in merged.iterrows():\n        now, before = row[value], row.get(f\"{value}_prior\")\n        if pd.isna(before) or not before:\n            facts.append({\"title\": str(row[key]), \"value\": f\"{now:,.0f} (new)\"})\n        else:\n            change = (now - before) \u002F before\n            arrow = \"▲\" if change >= 0 else \"▼\"\n            facts.append({\"title\": str(row[key]), \"value\": f\"{now:,.0f}  {arrow} {abs(change):.1%}\"})\n    return facts\n",[155,1666,1667,1689,1735,1744,1756,1782,1799,1835,1842,1863,1889,1942],{"__ignoreMap":153},[158,1668,1669,1671,1674,1677,1679,1681,1683,1685,1687],{"class":63,"line":160},[158,1670,238],{"class":187},[158,1672,1673],{"class":241}," facts_with_change",[158,1675,1676],{"class":191},"(current: pd.DataFrame, prior: pd.DataFrame, key: ",[158,1678,249],{"class":248},[158,1680,1454],{"class":191},[158,1682,249],{"class":248},[158,1684,1470],{"class":191},[158,1686,483],{"class":248},[158,1688,1475],{"class":191},[158,1690,1691,1694,1696,1699,1702,1704,1707,1710,1712,1715,1717,1720,1722,1725,1728,1730,1733],{"class":63,"line":220},[158,1692,1693],{"class":191},"    merged ",[158,1695,195],{"class":187},[158,1697,1698],{"class":191}," current.merge(prior, ",[158,1700,1701],{"class":287},"on",[158,1703,195],{"class":187},[158,1705,1706],{"class":191},"key, ",[158,1708,1709],{"class":287},"how",[158,1711,195],{"class":187},[158,1713,1714],{"class":167},"\"left\"",[158,1716,696],{"class":191},[158,1718,1719],{"class":287},"suffixes",[158,1721,195],{"class":187},[158,1723,1724],{"class":191},"(",[158,1726,1727],{"class":167},"\"\"",[158,1729,696],{"class":191},[158,1731,1732],{"class":167},"\"_prior\"",[158,1734,1424],{"class":191},[158,1736,1737,1739,1741],{"class":63,"line":228},[158,1738,506],{"class":191},[158,1740,195],{"class":187},[158,1742,1743],{"class":191}," []\n",[158,1745,1746,1749,1751,1753],{"class":63,"line":235},[158,1747,1748],{"class":187},"    for",[158,1750,1532],{"class":191},[158,1752,562],{"class":187},[158,1754,1755],{"class":191}," merged.iterrows():\n",[158,1757,1758,1761,1763,1766,1768,1770,1772,1775,1777,1780],{"class":63,"line":261},[158,1759,1760],{"class":191},"        now, before ",[158,1762,195],{"class":187},[158,1764,1765],{"class":191}," row[value], row.get(",[158,1767,531],{"class":187},[158,1769,534],{"class":167},[158,1771,293],{"class":537},[158,1773,1774],{"class":191},"value",[158,1776,546],{"class":537},[158,1778,1779],{"class":167},"_prior\"",[158,1781,342],{"class":191},[158,1783,1784,1787,1790,1793,1796],{"class":63,"line":272},[158,1785,1786],{"class":187},"        if",[158,1788,1789],{"class":191}," pd.isna(before) ",[158,1791,1792],{"class":187},"or",[158,1794,1795],{"class":187}," not",[158,1797,1798],{"class":191}," before:\n",[158,1800,1801,1804,1806,1808,1810,1813,1815,1817,1819,1821,1823,1826,1828,1830,1833],{"class":63,"line":284},[158,1802,1803],{"class":191},"            facts.append({",[158,1805,519],{"class":167},[158,1807,528],{"class":191},[158,1809,249],{"class":248},[158,1811,1812],{"class":191},"(row[key]), ",[158,1814,525],{"class":167},[158,1816,528],{"class":191},[158,1818,531],{"class":187},[158,1820,534],{"class":167},[158,1822,293],{"class":537},[158,1824,1825],{"class":191},"now",[158,1827,543],{"class":187},[158,1829,546],{"class":537},[158,1831,1832],{"class":167}," (new)\"",[158,1834,1636],{"class":191},[158,1836,1837,1840],{"class":63,"line":302},[158,1838,1839],{"class":187},"        else",[158,1841,258],{"class":191},[158,1843,1844,1847,1849,1852,1854,1857,1860],{"class":63,"line":316},[158,1845,1846],{"class":191},"            change ",[158,1848,195],{"class":187},[158,1850,1851],{"class":191}," (now ",[158,1853,1553],{"class":187},[158,1855,1856],{"class":191}," before) ",[158,1858,1859],{"class":187},"\u002F",[158,1861,1862],{"class":191}," before\n",[158,1864,1865,1868,1870,1873,1875,1878,1881,1883,1886],{"class":63,"line":322},[158,1866,1867],{"class":191},"            arrow ",[158,1869,195],{"class":187},[158,1871,1872],{"class":167}," \"▲\"",[158,1874,1259],{"class":187},[158,1876,1877],{"class":191}," change ",[158,1879,1880],{"class":187},">=",[158,1882,1572],{"class":248},[158,1884,1885],{"class":187}," else",[158,1887,1888],{"class":167}," \"▼\"\n",[158,1890,1891,1893,1895,1897,1899,1901,1903,1905,1907,1909,1911,1913,1915,1917,1920,1923,1925,1927,1930,1933,1936,1938,1940],{"class":63,"line":328},[158,1892,1803],{"class":191},[158,1894,519],{"class":167},[158,1896,528],{"class":191},[158,1898,249],{"class":248},[158,1900,1812],{"class":191},[158,1902,525],{"class":167},[158,1904,528],{"class":191},[158,1906,531],{"class":187},[158,1908,534],{"class":167},[158,1910,293],{"class":537},[158,1912,1825],{"class":191},[158,1914,543],{"class":187},[158,1916,546],{"class":537},[158,1918,1919],{"class":537},"  {",[158,1921,1922],{"class":191},"arrow",[158,1924,546],{"class":537},[158,1926,1339],{"class":537},[158,1928,1929],{"class":248},"abs",[158,1931,1932],{"class":191},"(change)",[158,1934,1935],{"class":187},":.1%",[158,1937,546],{"class":537},[158,1939,534],{"class":167},[158,1941,1636],{"class":191},[158,1943,1944,1946],{"class":63,"line":333},[158,1945,585],{"class":187},[158,1947,1643],{"class":191},[10,1949,1950],{},"Handling the missing prior value explicitly is what stops the card showing a percentage against\nnothing, which is the first thing that happens when a new region appears. Reading the prior figures\nfrom the previous run's stored summary — rather than recomputing them — also makes the comparison\nreproducible, which matters the first time somebody asks why a number moved.",[143,1952,1954],{"id":1953},"common-pitfalls","Common pitfalls",[1956,1957,1958,1974],"table",{},[1959,1960,1961],"thead",{},[1962,1963,1964,1968,1971],"tr",{},[1965,1966,1967],"th",{},"Symptom",[1965,1969,1970],{},"Cause",[1965,1972,1973],{},"Fix",[1975,1976,1977,2003,2018,2029,2040,2051],"tbody",{},[1962,1978,1979,1986,1996],{},[1980,1981,1982,1985],"td",{},[155,1983,1984],{},"400 Bad Request"," on a valid-looking card",[1980,1987,1988,1989,1992,1993],{},"Missing ",[155,1990,1991],{},"contentType"," or ",[155,1994,1995],{},"$schema",[1980,1997,1998,1999,2002],{},"Send the full ",[155,2000,2001],{},"message"," envelope shown above",[1962,2004,2005,2008,2015],{},[1980,2006,2007],{},"The card renders as plain text",[1980,2009,2010,2011,2014],{},"Posted ",[155,2012,2013],{},"{\"text\": ...}"," rather than an attachment",[1980,2016,2017],{},"Use the attachment structure",[1962,2019,2020,2023,2026],{},[1980,2021,2022],{},"The link works for you, not for the channel",[1980,2024,2025],{},"A personal OneDrive link, or an expired presigned URL",[1980,2027,2028],{},"Upload to the channel's own library",[1962,2030,2031,2034,2037],{},[1980,2032,2033],{},"Nothing posts and no error",[1980,2035,2036],{},"The webhook was recreated and the old URL retired",[1980,2038,2039],{},"Read the URL from configuration, not a constant",[1962,2041,2042,2045,2048],{},[1980,2043,2044],{},"The card is truncated",[1980,2046,2047],{},"Payload over the size limit",[1980,2049,2050],{},"Summarise and link; do not inline the table",[1962,2052,2053,2056,2059],{},[1980,2054,2055],{},"Failures are never announced",[1980,2057,2058],{},"The notifier only runs on success",[1980,2060,2061],{},"Post from the exception handler too",[143,2063,2065],{"id":2064},"keeping-the-channel-usable","Keeping the channel usable",[10,2067,2068],{},"The main risk with automated posting is not technical. A channel that receives four identical cards\nevery morning becomes one that nobody reads, and the report is then less visible than it was by\nemail. Two conventions keep it useful: post the exceptional and summarise the routine, and give\nscheduled runs a single daily card rather than one per artefact.",[148,2070,2072],{"className":205,"code":2071,"language":207,"meta":153,"style":153},"if failures or total_change > 0.2:\n    post_card(status_card(...))          # something happened\nelse:\n    post_card(report_card(...))          # one quiet daily summary\n",[155,2073,2074,2094,2108,2114],{"__ignoreMap":153},[158,2075,2076,2079,2082,2084,2087,2089,2092],{"class":63,"line":160},[158,2077,2078],{"class":187},"if",[158,2080,2081],{"class":191}," failures ",[158,2083,1792],{"class":187},[158,2085,2086],{"class":191}," total_change ",[158,2088,1569],{"class":187},[158,2090,2091],{"class":248}," 0.2",[158,2093,258],{"class":191},[158,2095,2096,2099,2102,2105],{"class":63,"line":220},[158,2097,2098],{"class":191},"    post_card(status_card(",[158,2100,2101],{"class":248},"...",[158,2103,2104],{"class":191},"))          ",[158,2106,2107],{"class":1001},"# something happened\n",[158,2109,2110,2112],{"class":63,"line":228},[158,2111,1265],{"class":187},[158,2113,258],{"class":191},[158,2115,2116,2119,2121,2123],{"class":63,"line":235},[158,2117,2118],{"class":191},"    post_card(report_card(",[158,2120,2101],{"class":248},[158,2122,2104],{"class":191},[158,2124,2125],{"class":1001},"# one quiet daily summary\n",[10,2127,2128],{},"That distinction — a routine card and an exception card, never both — is what keeps people reading\nthe channel six months later, which is the entire point of posting there.",[143,2130,2132],{"id":2131},"performance-and-scale","Performance and scale",[20,2134,29,2140,29,2143,29,2146,29,2150,29,2155,29,2162,29,2167,29,2173,29,2177,29,2180,29,2186,29,2191,29,2195,29,2197,29,2203,29,2208,29,2212],{"viewBox":2135,"role":23,"ariaLabelledBy":2136,"xmlns":27,"style":2139},"0 0 720 196",[2137,2138],"tm-rate-t","tm-rate-d","width:100%;max-width:720px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[31,2141,2142],{"id":2137},"Posting volume against how much the channel is read",[35,2144,2145],{"id":2138},"A single daily summary is read consistently, a card per region is tolerated for a while, and a card per artefact is muted quickly and also runs into the connector's rate limit.",[39,2147],{"x":41,"y":41,"width":2148,"height":2149,"fill":44},"720","196",[56,2151,2154],{"x":47,"y":2152,"style":2153},"56","font-size:12px;font-weight:600;fill:var(--text,#172033);text-anchor:start","one summary a day",[39,2156],{"x":2157,"y":2158,"width":2159,"height":2160,"rx":2161,"fill":52,"stroke":53},"200","40","344.0","26","6",[39,2163],{"x":43,"y":2164,"width":48,"height":2165,"rx":2166,"fill":85,"stroke":86},"41","24","5",[56,2168,2172],{"x":2169,"y":2170,"style":2171},"556.0","58","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start","read",[56,2174,2176],{"x":47,"y":2175,"style":2153},"100","one card per region",[39,2178],{"x":2157,"y":2179,"width":2159,"height":2160,"rx":2161,"fill":52,"stroke":53},"84",[39,2181],{"x":43,"y":2182,"width":2183,"height":2165,"rx":2166,"fill":2184,"stroke":2185},"85","148.4","#fdefd8","var(--gold,#b4740a)",[56,2187,2190],{"x":2169,"y":2188,"style":2189},"102","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:start","tolerated",[56,2192,2194],{"x":47,"y":2193,"style":2153},"144","one card per artefact",[39,2196],{"x":2157,"y":109,"width":2159,"height":2160,"rx":2161,"fill":52,"stroke":53},[39,2198],{"x":43,"y":2199,"width":2200,"height":2165,"rx":2166,"fill":2201,"stroke":2202},"129","342.0","#fee8f2","var(--accent,#d81b73)",[56,2204,2207],{"x":2169,"y":2205,"style":2206},"146","font-size:12px;font-weight:700;fill:var(--accent,#d81b73);text-anchor:start","muted, and throttled",[56,2209,2211],{"x":47,"y":47,"style":2210},"font-size:11.5px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:start","relative cost",[56,2213,2216],{"x":2214,"y":2215,"style":140},"360.0","186","the technical limit and the human one point the same way",[10,2218,2219,2220,2223],{},"Each post is one HTTPS request and costs nothing, but Teams throttles per connector: sustained\nposting returns 429 with a ",[155,2221,2222],{},"Retry-After"," header, and the practical ceiling is a handful of messages a\nminute per channel. A job that posts per region will hit it; one that posts a single summary will\nnever come close.",[148,2225,2227],{"className":205,"code":2226,"language":207,"meta":153,"style":153},"import time\n\ndef post_with_retry(card: dict, attempts: int = 4) -> None:\n    for attempt in range(attempts):\n        response = requests.post(os.environ[\"TEAMS_WEBHOOK_URL\"], json=card, timeout=30)\n        if response.status_code != 429:\n            response.raise_for_status()\n            return\n        time.sleep(int(response.headers.get(\"Retry-After\", 2 ** attempt)))\n    raise RuntimeError(\"Teams throttled the post after several attempts\")\n",[155,2228,2229,2236,2240,2267,2282,2309,2324,2329,2334,2358],{"__ignoreMap":153},[158,2230,2231,2233],{"class":63,"line":160},[158,2232,214],{"class":187},[158,2234,2235],{"class":191}," time\n",[158,2237,2238],{"class":63,"line":220},[158,2239,232],{"emptyLinePlaceholder":231},[158,2241,2242,2244,2247,2249,2251,2254,2256,2258,2261,2263,2265],{"class":63,"line":228},[158,2243,238],{"class":187},[158,2245,2246],{"class":241}," post_with_retry",[158,2248,915],{"class":191},[158,2250,483],{"class":248},[158,2252,2253],{"class":191},", attempts: ",[158,2255,1090],{"class":248},[158,2257,1464],{"class":187},[158,2259,2260],{"class":248}," 4",[158,2262,252],{"class":191},[158,2264,255],{"class":248},[158,2266,258],{"class":191},[158,2268,2269,2271,2274,2276,2279],{"class":63,"line":235},[158,2270,1748],{"class":187},[158,2272,2273],{"class":191}," attempt ",[158,2275,562],{"class":187},[158,2277,2278],{"class":248}," range",[158,2280,2281],{"class":191},"(attempts):\n",[158,2283,2284,2287,2289,2291,2293,2295,2297,2299,2301,2303,2305,2307],{"class":63,"line":261},[158,2285,2286],{"class":191},"        response ",[158,2288,195],{"class":187},[158,2290,933],{"class":191},[158,2292,278],{"class":167},[158,2294,938],{"class":191},[158,2296,941],{"class":287},[158,2298,195],{"class":187},[158,2300,946],{"class":191},[158,2302,949],{"class":287},[158,2304,195],{"class":187},[158,2306,310],{"class":248},[158,2308,342],{"class":191},[158,2310,2311,2313,2316,2319,2322],{"class":63,"line":272},[158,2312,1786],{"class":187},[158,2314,2315],{"class":191}," response.status_code ",[158,2317,2318],{"class":187},"!=",[158,2320,2321],{"class":248}," 429",[158,2323,258],{"class":191},[158,2325,2326],{"class":63,"line":284},[158,2327,2328],{"class":191},"            response.raise_for_status()\n",[158,2330,2331],{"class":63,"line":302},[158,2332,2333],{"class":187},"            return\n",[158,2335,2336,2339,2341,2344,2347,2349,2352,2355],{"class":63,"line":316},[158,2337,2338],{"class":191},"        time.sleep(",[158,2340,1090],{"class":248},[158,2342,2343],{"class":191},"(response.headers.get(",[158,2345,2346],{"class":167},"\"Retry-After\"",[158,2348,696],{"class":191},[158,2350,2351],{"class":248},"2",[158,2353,2354],{"class":187}," **",[158,2356,2357],{"class":191}," attempt)))\n",[158,2359,2360,2363,2366,2368,2371],{"class":63,"line":322},[158,2361,2362],{"class":187},"    raise",[158,2364,2365],{"class":248}," RuntimeError",[158,2367,1724],{"class":191},[158,2369,2370],{"class":167},"\"Teams throttled the post after several attempts\"",[158,2372,342],{"class":191},[10,2374,2375,2376,2378,2379,18],{},"Honouring ",[155,2377,2222],{}," rather than retrying immediately is the difference between recovering and\nbeing throttled harder, which is the same rule that applies to\n",[14,2380,2382],{"href":2381},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-a-slack-channel-with-python\u002F","Post an Excel Report to a Slack Channel with Python",[143,2384,2386],{"id":2385},"conclusion","Conclusion",[10,2388,2389,2390,2392,2393,2395],{},"Post an Adaptive Card rather than a line of text: a title, the headline figures as a ",[155,2391,966],{},", and a\nbutton linking to the workbook where it already lives. Colour the card by outcome and send one from\nthe exception handler as well as the success path, keep the payload small by linking rather than\ninlining, and honour ",[155,2394,2222],{}," when Teams throttles. One summary card a day stays read; four\nidentical ones do not.",[143,2397,2399],{"id":2398},"frequently-asked-questions","Frequently asked questions",[10,2401,2402,2406],{},[2403,2404,2405],"strong",{},"Can I attach a file to a Teams message?","\nNot through an incoming webhook — a webhook posts a card, not an attachment. Upload the workbook to the channel's SharePoint folder or to blob storage, then post a card containing a link to it. That is also better practice, since the file then has one location rather than many copies.",[10,2408,2409,2412],{},[2403,2410,2411],{},"Are Office 365 connectors being retired?","\nMicrosoft has been steering integrations towards Power Automate workflows and Graph. A Workflows-based endpoint accepts the same Adaptive Card payload, so the card you build here survives the migration even though the URL changes.",[10,2414,2415,2418],{},[2403,2416,2417],{},"What is the message size limit?","\nAround 28 KB for an Adaptive Card payload. A card summarising a report will not approach that; a card that embeds a table of a thousand rows will, which is a good reason to link rather than inline.",[10,2420,2421,2424],{},[2403,2422,2423],{},"How do I mention someone?","\nAdaptive Cards support mentions through an msteams entities block naming the user's Entra ID. It is worth reserving for genuine exceptions — a card that mentions the whole team every morning becomes noise within a week.",[143,2426,2428],{"id":2427},"related","Related",[2430,2431,2432,2439,2444,2449,2456],"ul",{},[2433,2434,2435,2436,2438],"li",{},"Up one level: ",[14,2437,17],{"href":16}," — where the file the card links to should live.",[2433,2440,2441,2443],{},[14,2442,2382],{"href":2381}," — the same pattern with a real file upload.",[2433,2445,2446,2448],{},[14,2447,1115],{"href":1114}," — putting the workbook where the channel can already reach it.",[2433,2450,2451,2455],{},[14,2452,2454],{"href":2453},"\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"," — deciding which card to post.",[2433,2457,2458,2462],{},[14,2459,2461],{"href":2460},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api\u002F","Send an Excel Report with the Microsoft Graph API"," — the email half of the same tenant integration.",[2464,2465,2466],"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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":153,"searchDepth":220,"depth":220,"links":2468},[2469,2470,2471,2472,2473,2474,2475,2476,2477,2478,2479,2480,2481],{"id":145,"depth":220,"text":146},{"id":201,"depth":220,"text":202},{"id":348,"depth":220,"text":349},{"id":970,"depth":220,"text":971},{"id":1119,"depth":220,"text":1120},{"id":1430,"depth":220,"text":1431},{"id":1657,"depth":220,"text":1658},{"id":1953,"depth":220,"text":1954},{"id":2064,"depth":220,"text":2065},{"id":2131,"depth":220,"text":2132},{"id":2385,"depth":220,"text":2386},{"id":2398,"depth":220,"text":2399},{"id":2427,"depth":220,"text":2428},"2026-09-04","Post an Adaptive Card carrying the report's headline figures and a link to the workbook — with outcome colouring, failure cards from the exception handler, and Retry-After handling.","md",[2486,2488,2490,2492],{"q":2405,"a":2487},"Not through an incoming webhook — a webhook posts a card, not an attachment. Upload the workbook to the channel's SharePoint folder or to blob storage, then post a card containing a link to it. That is also better practice, since the file then has one location rather than many copies.",{"q":2411,"a":2489},"Microsoft has been steering integrations towards Power Automate workflows and Graph. A Workflows-based endpoint accepts the same Adaptive Card payload, so the card you build here survives the migration even though the URL changes.",{"q":2417,"a":2491},"Around 28 KB for an Adaptive Card payload. A card summarising a report will not approach that; a card that embeds a table of a thousand rows will, which is a good reason to link rather than inline.",{"q":2423,"a":2493},"Adaptive Cards support mentions through an msteams entities block naming the user's Entra ID. It is worth reserving for genuine exceptions — a card that mentions the whole team every morning becomes noise within a week.",{"breadcrumb":2495},[2496,2498,2501],{"name":2497,"item":1859},"Home",{"name":2499,"item":2500},"Automating Reporting Workflows","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-microsoft-teams-with-python",{"title":5,"description":2504},"Announce a generated Excel report in Teams with an Adaptive Card — FactSet summaries, an Open URL action, good and attention styling, size limits and throttling with Retry-After.","post-an-excel-report-to-microsoft-teams-with-python","automating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-microsoft-teams-with-python\u002Findex","how-to","YcsEDwNQPBYAELI25bAIrKgRyLEFyh72nwRf1BivxLY",[2510,2513],{"title":2382,"path":2511,"stem":2512,"children":-1},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-a-slack-channel-with-python","automating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-a-slack-channel-with-python\u002Findex",{"title":2514,"path":2515,"stem":2516,"children":-1},"Read an Excel File Directly from Amazon S3 with pandas","\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fread-an-excel-file-directly-from-amazon-s3-with-pandas","automating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fread-an-excel-file-directly-from-amazon-s3-with-pandas\u002Findex",1788710159088]