[{"data":1,"prerenderedAt":1819},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv":3,"surround:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv":1784,"ancestors:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv":1793,"children:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv":1798,"related:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv":1799},{"id":4,"title":5,"body":6,"dateModified":1752,"datePublished":1752,"description":1753,"extension":1754,"faq":1755,"keyword":1764,"kind":1765,"meta":1766,"navigation":624,"path":1774,"seo":1775,"slug":1778,"stem":1779,"topics":1780,"type":1782,"__hash__":1783},"docs\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv\u002Findex.md","Set Up an Excel Automation Project with uv",{"type":7,"value":8,"toc":1738},"minimark",[9,36,41,48,87,262,266,276,354,368,372,389,476,489,566,570,580,958,1098,1172,1185,1189,1192,1245,1261,1330,1340,1344,1351,1398,1421,1547,1551,1569,1573,1579,1583,1637,1641,1673,1677,1683,1689,1695,1701,1705,1734],[10,11,12,13,17,18,21,22,25,26,29,30,35],"p",{},"Most report scripts start life as ",[14,15,16],"code",{},"report.py"," next to whatever Python happened to be installed, with packages added by ",[14,19,20],{},"pip install"," as errors demanded them. Six months later the script runs on one laptop and nowhere else: a colleague's pandas is a major version newer, the server's Python is older, and nobody knows which openpyxl produced the file that finance signed off. uv, a fast Python package and project manager, fixes that with very little ceremony: one command creates a project with a pinned Python version, ",[14,23,24],{},"uv add"," records dependencies and resolves them into a lock file, and ",[14,27,28],{},"uv run"," executes the report in exactly that environment everywhere. This page is part of ",[31,32,34],"a",{"href":33},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002F","Testing and Packaging Excel Automation Scripts",".",[37,38,40],"h2",{"id":39},"prerequisites","Prerequisites",[10,42,43,44,47],{},"Install uv once per machine — from the installer script on astral.sh, ",[14,45,46],{},"pipx install uv",", or a package manager. It manages Python versions and virtual environments itself, so nothing else is needed. Every command on this page was run as shown; outputs are trimmed where they are long.",[49,50,55],"pre",{"className":51,"code":52,"language":53,"meta":54,"style":54},"language-bash shiki shiki-themes github-light github-dark-high-contrast","uv --version | cut -d' ' -f1-2\n","bash","",[14,56,57],{"__ignoreMap":54},[58,59,62,66,70,74,77,80,84],"span",{"class":60,"line":61},"line",1,[58,63,65],{"class":64},"sMTad","uv",[58,67,69],{"class":68},"sP0c6"," --version",[58,71,73],{"class":72},"s-kum"," |",[58,75,76],{"class":64}," cut",[58,78,79],{"class":68}," -d",[58,81,83],{"class":82},"srMev","' '",[58,85,86],{"class":68}," -f1-2\n",[88,89,97,98,97,102,97,106,97,114,97,124,97,135,97,142,97,147,97,151,97,157,97,162,97,166,97,173,97,178,97,182,97,186,97,189,97,192,97,198,97,202,97,205,97,209,97,212,97,217,97,224,97,228,97,231,97,235,97,238,97,243,97,250,97,255,97,259],"svg",{"viewBox":90,"role":91,"ariaLabelledBy":92,"xmlns":95,"style":96},"4 0 752 142","img",[93,94],"uv-flow-t","uv-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[99,100,101],"title",{"id":93},"From a script to a reproducible project",[103,104,105],"desc",{"id":94},"uv init creates the project with a pinned Python version, uv add records dependencies and writes the lock file, the report becomes a console command, uv run executes it and its tests, and servers install exactly the locked versions with uv sync --locked.",[107,108],"rect",{"x":109,"y":110,"width":111,"height":112,"fill":113},"4","0","752","142","#ffffff",[107,115],{"x":116,"y":117,"width":118,"height":119,"rx":120,"fill":121,"stroke":122,"style":123},"20.0","16.0","115.2","110.5","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[125,126,128],"g",{"transform":127},"translate(65.6,29.0) scale(1.000)",[129,130],"path",{"d":131,"fill":132,"stroke":133,"style":134},"M3 6h6l2 2h10v11H3z","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[136,137,141],"text",{"x":138,"y":139,"style":140},"77.6","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","uv init",[136,143,146],{"x":138,"y":144,"style":145},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","project +",[136,148,150],{"x":138,"y":149,"style":145},"112.5","Python pin",[60,152],{"x1":153,"y1":154,"x2":155,"y2":154,"stroke":156,"style":123},"140.2","71.2","162.2","var(--brand,#5b5cf0)",[158,159],"polygon",{"points":160,"fill":161},"166.2,71.2 157.2,76.2 157.2,66.2","#5b5cf0",[107,163],{"x":164,"y":117,"width":118,"height":119,"rx":120,"fill":165,"stroke":156,"style":123},"171.2","#f0f4ff",[125,167,169],{"transform":168},"translate(216.8,37.8) scale(1.000)",[129,170],{"d":171,"fill":132,"stroke":172,"style":134},"M12 2.5l8.5 4.7v9.6L12 21.5l-8.5-4.7V7.2z M3.5 7.2l8.5 4.7 8.5-4.7 M12 11.9v9.6","var(--brand-strong,#4338ca)",[136,174,24],{"x":175,"y":176,"style":177},"228.8","84.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[136,179,181],{"x":175,"y":180,"style":145},"103.8","deps + uv.lock",[60,183],{"x1":184,"y1":154,"x2":185,"y2":154,"stroke":156,"style":123},"291.4","313.4",[158,187],{"points":188,"fill":161},"317.4,71.2 308.4,76.2 308.4,66.2",[107,190],{"x":191,"y":117,"width":118,"height":119,"rx":120,"fill":165,"stroke":156,"style":123},"322.4",[125,193,195],{"transform":194},"translate(368.0,37.8) scale(1.000)",[129,196],{"d":197,"fill":132,"stroke":172,"style":134},"M4 5h16v14H4z M7 9l3 3-3 3 M12 15h5",[136,199,201],{"x":200,"y":176,"style":177},"380.0","Command",[136,203,204],{"x":200,"y":180,"style":145},"[project.scripts]",[60,206],{"x1":207,"y1":154,"x2":208,"y2":154,"stroke":156,"style":123},"442.6","464.6",[158,210],{"points":211,"fill":161},"468.6,71.2 459.6,76.2 459.6,66.2",[107,213],{"x":214,"y":117,"width":118,"height":119,"rx":120,"fill":215,"stroke":216,"style":123},"473.6","#d9f4f1","var(--teal,#0f9488)",[125,218,220],{"transform":219},"translate(519.2,37.8) scale(1.000)",[129,221],{"d":222,"fill":132,"stroke":223,"style":134},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[136,225,28],{"x":226,"y":176,"style":227},"531.2","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[136,229,230],{"x":226,"y":180,"style":145},"report and tests",[60,232],{"x1":233,"y1":154,"x2":234,"y2":154,"stroke":156,"style":123},"593.8","615.8",[158,236],{"points":237,"fill":161},"619.8,71.2 610.8,76.2 610.8,66.2",[107,239],{"x":240,"y":117,"width":118,"height":119,"rx":120,"fill":241,"stroke":242,"style":123},"624.8","#fdefd8","var(--gold,#b4740a)",[125,244,246],{"transform":245},"translate(670.4,29.0) scale(1.000)",[129,247],{"d":248,"fill":132,"stroke":249,"style":134},"M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01","var(--gold-ink,#7a4e06)",[136,251,254],{"x":252,"y":139,"style":253},"682.4","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","uv sync",[136,256,258],{"x":252,"y":257,"style":253},"93.0","--locked",[136,260,261],{"x":252,"y":149,"style":145},"servers and CI",[37,263,265],{"id":264},"step-1-create-the-project","Step 1 — Create the project",[10,267,268,271,272,275],{},[14,269,270],{},"uv init --package"," creates a project laid out as an installable package, with its code under ",[14,273,274],{},"src\u002F",", which is what makes a proper command for the report possible later. Pin the Python version the report is tested with:",[49,277,279],{"className":51,"code":278,"language":53,"meta":54,"style":54},"uv init --package --python 3.12 --author-from none --vcs none weekly-report > \u002Fdev\u002Fnull\ncd weekly-report && cat .python-version && find . -type f | sort\n",[14,280,281,317],{"__ignoreMap":54},[58,282,283,285,288,291,294,297,300,303,306,308,311,314],{"class":60,"line":61},[58,284,65],{"class":64},[58,286,287],{"class":82}," init",[58,289,290],{"class":68}," --package",[58,292,293],{"class":68}," --python",[58,295,296],{"class":68}," 3.12",[58,298,299],{"class":68}," --author-from",[58,301,302],{"class":82}," none",[58,304,305],{"class":68}," --vcs",[58,307,302],{"class":82},[58,309,310],{"class":82}," weekly-report",[58,312,313],{"class":72}," >",[58,315,316],{"class":82}," \u002Fdev\u002Fnull\n",[58,318,320,323,325,329,332,335,337,340,343,346,349,351],{"class":60,"line":319},2,[58,321,322],{"class":68},"cd",[58,324,310],{"class":82},[58,326,328],{"class":327},"skGVy"," && ",[58,330,331],{"class":64},"cat",[58,333,334],{"class":82}," .python-version",[58,336,328],{"class":327},[58,338,339],{"class":64},"find",[58,341,342],{"class":82}," .",[58,344,345],{"class":68}," -type",[58,347,348],{"class":82}," f",[58,350,73],{"class":72},[58,352,353],{"class":64}," sort\n",[10,355,356,359,360,363,364,367],{},[14,357,358],{},".python-version"," pins the interpreter: uv uses — or downloads — Python 3.12 for this project whatever the machine's default is. ",[14,361,362],{},"--author-from none"," keeps names and email addresses out of the generated metadata; ",[14,365,366],{},"--vcs none"," skips creating a git repository, which you would normally want, and is only omitted here to keep the listing short.",[37,369,371],{"id":370},"step-2-add-the-dependencies","Step 2 — Add the dependencies",[10,373,374,376,377,380,381,384,385,388],{},[14,375,24],{}," installs packages into the project's own ",[14,378,379],{},".venv",", records them in ",[14,382,383],{},"pyproject.toml"," and resolves the complete set of versions into ",[14,386,387],{},"uv.lock",". Test tools go into a separate development group:",[49,390,392],{"className":51,"code":391,"language":53,"meta":54,"style":54},"cd weekly-report\nuv add --quiet pandas openpyxl xlsxwriter\nuv add --quiet --dev pytest\nsed -n '\u002F^dependencies\u002F,\u002F^]\u002Fp;\u002F^\\[dependency-groups\\]\u002F,\u002F^]\u002Fp' pyproject.toml\ngrep -c '^\\[\\[package\\]\\]' uv.lock | xargs echo \"packages in uv.lock:\"\n",[14,393,394,401,420,435,450],{"__ignoreMap":54},[58,395,396,398],{"class":60,"line":61},[58,397,322],{"class":68},[58,399,400],{"class":82}," weekly-report\n",[58,402,403,405,408,411,414,417],{"class":60,"line":319},[58,404,65],{"class":64},[58,406,407],{"class":82}," add",[58,409,410],{"class":68}," --quiet",[58,412,413],{"class":82}," pandas",[58,415,416],{"class":82}," openpyxl",[58,418,419],{"class":82}," xlsxwriter\n",[58,421,423,425,427,429,432],{"class":60,"line":422},3,[58,424,65],{"class":64},[58,426,407],{"class":82},[58,428,410],{"class":68},[58,430,431],{"class":68}," --dev",[58,433,434],{"class":82}," pytest\n",[58,436,438,441,444,447],{"class":60,"line":437},4,[58,439,440],{"class":64},"sed",[58,442,443],{"class":68}," -n",[58,445,446],{"class":82}," '\u002F^dependencies\u002F,\u002F^]\u002Fp;\u002F^\\[dependency-groups\\]\u002F,\u002F^]\u002Fp'",[58,448,449],{"class":82}," pyproject.toml\n",[58,451,453,456,459,462,465,467,470,473],{"class":60,"line":452},5,[58,454,455],{"class":64},"grep",[58,457,458],{"class":68}," -c",[58,460,461],{"class":82}," '^\\[\\[package\\]\\]'",[58,463,464],{"class":82}," uv.lock",[58,466,73],{"class":72},[58,468,469],{"class":64}," xargs",[58,471,472],{"class":82}," echo",[58,474,475],{"class":82}," \"packages in uv.lock:\"\n",[10,477,478,480,481,483,484,488],{},[14,479,383],{}," lists what the project asked for, with a lower bound; ",[14,482,387],{}," pins every package — including pandas' own dependencies such as numpy — to one exact version with hashes, for every platform. Commit both. The lock file is what makes the environment on the server identical to the one on the laptop where the report was checked. ",[31,485,487],{"href":486},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible\u002F","Make Excel Output Reproducible"," covers the rest of reproducibility — fixed dates, sorted output, stable styles.",[88,490,97,495,97,498,97,501,97,504,97,509,97,516,97,521,97,527,97,532,97,536,97,540,97,543,97,549,97,553,97,557,97,560,97,563],{"viewBox":491,"role":91,"ariaLabelledBy":492,"xmlns":95,"style":96},"4 0 752 207",[493,494],"uv-files-t","uv-files-d",[99,496,497],{"id":493},"pyproject.toml and uv.lock",[103,499,500],{"id":494},"Two panels. pyproject.toml records the project's direct dependencies with version bounds and is edited by uv add. uv.lock records the exact resolved version and hash of every package, direct and indirect, and is what locked installs use.",[107,502],{"x":109,"y":110,"width":111,"height":503,"fill":113},"207",[107,505],{"x":116,"y":117,"width":506,"height":507,"rx":508,"fill":165,"stroke":156,"style":123},"351.0","175.0","14",[125,510,512],{"transform":511},"translate(182.5,29.0) scale(1.083)",[129,513],{"d":514,"fill":132,"stroke":172,"style":515},"M6 3h9l4 4v14H6z M15 3v4h4","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[136,517,383],{"x":518,"y":519,"style":520},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[60,522],{"x1":523,"y1":524,"x2":525,"y2":524,"stroke":156,"style":526},"36.0","91.0","355.0","stroke-width:1px",[136,528,531],{"x":518,"y":529,"style":530},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","what the project asks for",[136,533,535],{"x":518,"y":534,"style":530},"137.0","pandas>=…, openpyxl>=…",[136,537,539],{"x":518,"y":538,"style":530},"163.0","edited by uv add \u002F uv remove",[107,541],{"x":542,"y":117,"width":506,"height":507,"rx":508,"fill":215,"stroke":216,"style":123},"389.0",[125,544,546],{"transform":545},"translate(551.5,29.0) scale(1.083)",[129,547],{"d":548,"fill":132,"stroke":223,"style":515},"M6 11h12v9H6z M8.5 11V8a3.5 3.5 0 0 1 7 0v3",[136,550,387],{"x":551,"y":519,"style":552},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[60,554],{"x1":555,"y1":524,"x2":556,"y2":524,"stroke":216,"style":526},"405.0","724.0",[136,558,559],{"x":551,"y":529,"style":530},"every package, exact version",[136,561,562],{"x":551,"y":534,"style":530},"hashes, all platforms",[136,564,565],{"x":551,"y":538,"style":530},"used by uv sync --locked",[37,567,569],{"id":568},"step-3-make-the-report-a-command","Step 3 — Make the report a command",[10,571,572,573,576,577,579],{},"The generated package has a ",[14,574,575],{},"main()"," function registered as a command in ",[14,578,204],{},". Put the report there — a small one here — and a test beside it:",[49,581,585],{"className":582,"code":583,"language":584,"meta":54,"style":54},"language-python shiki shiki-themes github-light github-dark-high-contrast","import argparse\nfrom datetime import date\nfrom pathlib import Path\n\nimport pandas as pd\n\ndef build(report_date: date, out_dir: Path) -> Path:\n    df = pd.DataFrame({\"Region\": [\"North\", \"South\", \"East\", \"West\"], \"Sales\": [482100, 391250, 297800, 259000]})\n    out = out_dir \u002F f\"weekly_sales_{report_date:%Y-%m-%d}.xlsx\"\n    out_dir.mkdir(parents=True, exist_ok=True)\n    df.sort_values(\"Sales\", ascending=False).to_excel(out, index=False)\n    return out\n\ndef main() -> None:\n    p = argparse.ArgumentParser(prog=\"weekly-report\")\n    p.add_argument(\"--date\", type=date.fromisoformat, default=date.today())\n    p.add_argument(\"--out\", type=Path, default=Path(\"reports\"))\n    args = p.parse_args()\n    print(build(args.date, args.out))\n","python",[14,586,587,595,608,620,626,639,644,657,723,772,799,829,838,843,860,881,908,938,949],{"__ignoreMap":54},[58,588,589,592],{"class":60,"line":61},[58,590,591],{"class":72},"import",[58,593,594],{"class":327}," argparse\n",[58,596,597,600,603,605],{"class":60,"line":319},[58,598,599],{"class":72},"from",[58,601,602],{"class":327}," datetime ",[58,604,591],{"class":72},[58,606,607],{"class":327}," date\n",[58,609,610,612,615,617],{"class":60,"line":422},[58,611,599],{"class":72},[58,613,614],{"class":327}," pathlib ",[58,616,591],{"class":72},[58,618,619],{"class":327}," Path\n",[58,621,622],{"class":60,"line":437},[58,623,625],{"emptyLinePlaceholder":624},true,"\n",[58,627,628,630,633,636],{"class":60,"line":452},[58,629,591],{"class":72},[58,631,632],{"class":327}," pandas ",[58,634,635],{"class":72},"as",[58,637,638],{"class":327}," pd\n",[58,640,642],{"class":60,"line":641},6,[58,643,625],{"emptyLinePlaceholder":624},[58,645,647,650,654],{"class":60,"line":646},7,[58,648,649],{"class":72},"def",[58,651,653],{"class":652},"s_Opv"," build",[58,655,656],{"class":327},"(report_date: date, out_dir: Path) -> Path:\n",[58,658,660,663,666,669,672,675,678,681,684,686,689,691,694,697,700,702,705,707,710,712,715,717,720],{"class":60,"line":659},8,[58,661,662],{"class":327},"    df ",[58,664,665],{"class":72},"=",[58,667,668],{"class":327}," pd.DataFrame({",[58,670,671],{"class":82},"\"Region\"",[58,673,674],{"class":327},": [",[58,676,677],{"class":82},"\"North\"",[58,679,680],{"class":327},", ",[58,682,683],{"class":82},"\"South\"",[58,685,680],{"class":327},[58,687,688],{"class":82},"\"East\"",[58,690,680],{"class":327},[58,692,693],{"class":82},"\"West\"",[58,695,696],{"class":327},"], ",[58,698,699],{"class":82},"\"Sales\"",[58,701,674],{"class":327},[58,703,704],{"class":68},"482100",[58,706,680],{"class":327},[58,708,709],{"class":68},"391250",[58,711,680],{"class":327},[58,713,714],{"class":68},"297800",[58,716,680],{"class":327},[58,718,719],{"class":68},"259000",[58,721,722],{"class":327},"]})\n",[58,724,726,729,731,734,737,739,742,746,749,752,755,758,761,763,766,769],{"class":60,"line":725},9,[58,727,728],{"class":327},"    out ",[58,730,665],{"class":72},[58,732,733],{"class":327}," out_dir ",[58,735,736],{"class":72},"\u002F",[58,738,348],{"class":72},[58,740,741],{"class":82},"\"weekly_sales_",[58,743,745],{"class":744},"sSjpA","{",[58,747,748],{"class":327},"report_date:",[58,750,751],{"class":72},"%",[58,753,754],{"class":327},"Y",[58,756,757],{"class":72},"-%",[58,759,760],{"class":327},"m",[58,762,757],{"class":72},[58,764,765],{"class":327},"d",[58,767,768],{"class":744},"}",[58,770,771],{"class":82},".xlsx\"\n",[58,773,775,778,782,784,787,789,792,794,796],{"class":60,"line":774},10,[58,776,777],{"class":327},"    out_dir.mkdir(",[58,779,781],{"class":780},"sa561","parents",[58,783,665],{"class":72},[58,785,786],{"class":68},"True",[58,788,680],{"class":327},[58,790,791],{"class":780},"exist_ok",[58,793,665],{"class":72},[58,795,786],{"class":68},[58,797,798],{"class":327},")\n",[58,800,802,805,807,809,812,814,817,820,823,825,827],{"class":60,"line":801},11,[58,803,804],{"class":327},"    df.sort_values(",[58,806,699],{"class":82},[58,808,680],{"class":327},[58,810,811],{"class":780},"ascending",[58,813,665],{"class":72},[58,815,816],{"class":68},"False",[58,818,819],{"class":327},").to_excel(out, ",[58,821,822],{"class":780},"index",[58,824,665],{"class":72},[58,826,816],{"class":68},[58,828,798],{"class":327},[58,830,832,835],{"class":60,"line":831},12,[58,833,834],{"class":72},"    return",[58,836,837],{"class":327}," out\n",[58,839,841],{"class":60,"line":840},13,[58,842,625],{"emptyLinePlaceholder":624},[58,844,846,848,851,854,857],{"class":60,"line":845},14,[58,847,649],{"class":72},[58,849,850],{"class":652}," main",[58,852,853],{"class":327},"() -> ",[58,855,856],{"class":68},"None",[58,858,859],{"class":327},":\n",[58,861,863,866,868,871,874,876,879],{"class":60,"line":862},15,[58,864,865],{"class":327},"    p ",[58,867,665],{"class":72},[58,869,870],{"class":327}," argparse.ArgumentParser(",[58,872,873],{"class":780},"prog",[58,875,665],{"class":72},[58,877,878],{"class":82},"\"weekly-report\"",[58,880,798],{"class":327},[58,882,884,887,890,892,895,897,900,903,905],{"class":60,"line":883},16,[58,885,886],{"class":327},"    p.add_argument(",[58,888,889],{"class":82},"\"--date\"",[58,891,680],{"class":327},[58,893,894],{"class":780},"type",[58,896,665],{"class":72},[58,898,899],{"class":327},"date.fromisoformat, ",[58,901,902],{"class":780},"default",[58,904,665],{"class":72},[58,906,907],{"class":327},"date.today())\n",[58,909,911,913,916,918,920,922,925,927,929,932,935],{"class":60,"line":910},17,[58,912,886],{"class":327},[58,914,915],{"class":82},"\"--out\"",[58,917,680],{"class":327},[58,919,894],{"class":780},[58,921,665],{"class":72},[58,923,924],{"class":327},"Path, ",[58,926,902],{"class":780},[58,928,665],{"class":72},[58,930,931],{"class":327},"Path(",[58,933,934],{"class":82},"\"reports\"",[58,936,937],{"class":327},"))\n",[58,939,941,944,946],{"class":60,"line":940},18,[58,942,943],{"class":327},"    args ",[58,945,665],{"class":72},[58,947,948],{"class":327}," p.parse_args()\n",[58,950,952,955],{"class":60,"line":951},19,[58,953,954],{"class":68},"    print",[58,956,957],{"class":327},"(build(args.date, args.out))\n",[49,959,961],{"className":582,"code":960,"language":584,"meta":54,"style":54},"from datetime import date\n\nimport pandas as pd\n\nfrom weekly_report import build\n\ndef test_build_sorts_regions(tmp_path):\n    out = build(date(2026, 9, 28), tmp_path)\n    df = pd.read_excel(out)\n    assert out.name == \"weekly_sales_2026-09-28.xlsx\"\n    assert df[\"Region\"].tolist()[0] == \"North\"\n    assert df[\"Sales\"].is_monotonic_decreasing\n",[14,962,963,973,977,987,991,1003,1007,1017,1042,1051,1065,1087],{"__ignoreMap":54},[58,964,965,967,969,971],{"class":60,"line":61},[58,966,599],{"class":72},[58,968,602],{"class":327},[58,970,591],{"class":72},[58,972,607],{"class":327},[58,974,975],{"class":60,"line":319},[58,976,625],{"emptyLinePlaceholder":624},[58,978,979,981,983,985],{"class":60,"line":422},[58,980,591],{"class":72},[58,982,632],{"class":327},[58,984,635],{"class":72},[58,986,638],{"class":327},[58,988,989],{"class":60,"line":437},[58,990,625],{"emptyLinePlaceholder":624},[58,992,993,995,998,1000],{"class":60,"line":452},[58,994,599],{"class":72},[58,996,997],{"class":327}," weekly_report ",[58,999,591],{"class":72},[58,1001,1002],{"class":327}," build\n",[58,1004,1005],{"class":60,"line":641},[58,1006,625],{"emptyLinePlaceholder":624},[58,1008,1009,1011,1014],{"class":60,"line":646},[58,1010,649],{"class":72},[58,1012,1013],{"class":652}," test_build_sorts_regions",[58,1015,1016],{"class":327},"(tmp_path):\n",[58,1018,1019,1021,1023,1026,1029,1031,1034,1036,1039],{"class":60,"line":659},[58,1020,728],{"class":327},[58,1022,665],{"class":72},[58,1024,1025],{"class":327}," build(date(",[58,1027,1028],{"class":68},"2026",[58,1030,680],{"class":327},[58,1032,1033],{"class":68},"9",[58,1035,680],{"class":327},[58,1037,1038],{"class":68},"28",[58,1040,1041],{"class":327},"), tmp_path)\n",[58,1043,1044,1046,1048],{"class":60,"line":725},[58,1045,662],{"class":327},[58,1047,665],{"class":72},[58,1049,1050],{"class":327}," pd.read_excel(out)\n",[58,1052,1053,1056,1059,1062],{"class":60,"line":774},[58,1054,1055],{"class":72},"    assert",[58,1057,1058],{"class":327}," out.name ",[58,1060,1061],{"class":72},"==",[58,1063,1064],{"class":82}," \"weekly_sales_2026-09-28.xlsx\"\n",[58,1066,1067,1069,1072,1074,1077,1079,1082,1084],{"class":60,"line":801},[58,1068,1055],{"class":72},[58,1070,1071],{"class":327}," df[",[58,1073,671],{"class":82},[58,1075,1076],{"class":327},"].tolist()[",[58,1078,110],{"class":68},[58,1080,1081],{"class":327},"] ",[58,1083,1061],{"class":72},[58,1085,1086],{"class":82}," \"North\"\n",[58,1088,1089,1091,1093,1095],{"class":60,"line":831},[58,1090,1055],{"class":72},[58,1092,1071],{"class":327},[58,1094,699],{"class":82},[58,1096,1097],{"class":327},"].is_monotonic_decreasing\n",[49,1099,1101],{"className":51,"code":1100,"language":53,"meta":54,"style":54},"cd weekly-report\ngrep -A1 '^\\[project.scripts\\]' pyproject.toml\nuv run --quiet weekly-report --date 2026-09-28\nuv run --quiet pytest -q -p no:cacheprovider tests 2>&1 | tail -1\n",[14,1102,1103,1109,1121,1138],{"__ignoreMap":54},[58,1104,1105,1107],{"class":60,"line":61},[58,1106,322],{"class":68},[58,1108,400],{"class":82},[58,1110,1111,1113,1116,1119],{"class":60,"line":319},[58,1112,455],{"class":64},[58,1114,1115],{"class":68}," -A1",[58,1117,1118],{"class":82}," '^\\[project.scripts\\]'",[58,1120,449],{"class":82},[58,1122,1123,1125,1128,1130,1132,1135],{"class":60,"line":422},[58,1124,65],{"class":64},[58,1126,1127],{"class":82}," run",[58,1129,410],{"class":68},[58,1131,310],{"class":82},[58,1133,1134],{"class":68}," --date",[58,1136,1137],{"class":82}," 2026-09-28\n",[58,1139,1140,1142,1144,1146,1149,1152,1155,1158,1161,1164,1166,1169],{"class":60,"line":437},[58,1141,65],{"class":64},[58,1143,1127],{"class":82},[58,1145,410],{"class":68},[58,1147,1148],{"class":82}," pytest",[58,1150,1151],{"class":68}," -q",[58,1153,1154],{"class":68}," -p",[58,1156,1157],{"class":82}," no:cacheprovider",[58,1159,1160],{"class":82}," tests",[58,1162,1163],{"class":72}," 2>&1",[58,1165,73],{"class":72},[58,1167,1168],{"class":64}," tail",[58,1170,1171],{"class":68}," -1\n",[10,1173,1174,1177,1178,1181,1182,1184],{},[14,1175,1176],{},"uv run weekly-report"," checks that the environment matches the lock file — installing or syncing if needed — and runs the command inside it; there is nothing to activate. ",[14,1179,1180],{},"uv run pytest"," does the same for the tests, with the development group included. The first ",[14,1183,28],{}," after cloning the repository sets everything up; later ones start instantly.",[37,1186,1188],{"id":1187},"step-4-install-exactly-the-locked-versions-elsewhere","Step 4 — Install exactly the locked versions elsewhere",[10,1190,1191],{},"On a server, a scheduled job's machine or a CI runner, install from the lock file and refuse to change it:",[49,1193,1195],{"className":51,"code":1194,"language":53,"meta":54,"style":54},"cd weekly-report\nuv sync --locked --no-dev --quiet && echo \"server environment matches uv.lock\"\nuv lock --check --quiet && echo \"lock file is up to date with pyproject.toml\"\n",[14,1196,1197,1203,1226],{"__ignoreMap":54},[58,1198,1199,1201],{"class":60,"line":61},[58,1200,322],{"class":68},[58,1202,400],{"class":82},[58,1204,1205,1207,1210,1213,1216,1218,1220,1223],{"class":60,"line":319},[58,1206,65],{"class":64},[58,1208,1209],{"class":82}," sync",[58,1211,1212],{"class":68}," --locked",[58,1214,1215],{"class":68}," --no-dev",[58,1217,410],{"class":68},[58,1219,328],{"class":327},[58,1221,1222],{"class":68},"echo",[58,1224,1225],{"class":82}," \"server environment matches uv.lock\"\n",[58,1227,1228,1230,1233,1236,1238,1240,1242],{"class":60,"line":422},[58,1229,65],{"class":64},[58,1231,1232],{"class":82}," lock",[58,1234,1235],{"class":68}," --check",[58,1237,410],{"class":68},[58,1239,328],{"class":327},[58,1241,1222],{"class":68},[58,1243,1244],{"class":82}," \"lock file is up to date with pyproject.toml\"\n",[10,1246,1247,1249,1250,1252,1253,1256,1257,1260],{},[14,1248,258],{}," fails if ",[14,1251,383],{}," has changed without the lock file being updated — the classic \"works on my machine\" gap — instead of silently resolving new versions. ",[14,1254,1255],{},"--no-dev"," leaves pytest and other development tools off production machines. For environments that cannot run uv, export the lock file as a pinned requirements file with hashes for plain ",[14,1258,1259],{},"pip install -r",":",[49,1262,1264],{"className":51,"code":1263,"language":53,"meta":54,"style":54},"cd weekly-report\nuv export --format requirements-txt --no-dev --no-emit-project --quiet > requirements.txt\ngrep -c '==' requirements.txt | xargs echo \"pinned packages:\"\ngrep -m1 '^pandas==' requirements.txt\n",[14,1265,1266,1272,1297,1318],{"__ignoreMap":54},[58,1267,1268,1270],{"class":60,"line":61},[58,1269,322],{"class":68},[58,1271,400],{"class":82},[58,1273,1274,1276,1279,1282,1285,1287,1290,1292,1294],{"class":60,"line":319},[58,1275,65],{"class":64},[58,1277,1278],{"class":82}," export",[58,1280,1281],{"class":68}," --format",[58,1283,1284],{"class":82}," requirements-txt",[58,1286,1215],{"class":68},[58,1288,1289],{"class":68}," --no-emit-project",[58,1291,410],{"class":68},[58,1293,313],{"class":72},[58,1295,1296],{"class":82}," requirements.txt\n",[58,1298,1299,1301,1303,1306,1309,1311,1313,1315],{"class":60,"line":422},[58,1300,455],{"class":64},[58,1302,458],{"class":68},[58,1304,1305],{"class":82}," '=='",[58,1307,1308],{"class":82}," requirements.txt",[58,1310,73],{"class":72},[58,1312,469],{"class":64},[58,1314,472],{"class":82},[58,1316,1317],{"class":82}," \"pinned packages:\"\n",[58,1319,1320,1322,1325,1328],{"class":60,"line":437},[58,1321,455],{"class":64},[58,1323,1324],{"class":68}," -m1",[58,1326,1327],{"class":82}," '^pandas=='",[58,1329,1296],{"class":82},[10,1331,1332,1333,1335,1336,1339],{},"Every package appears with an exact ",[14,1334,1061],{}," version and ",[14,1337,1338],{},"--hash"," lines, so pip refuses to install anything that does not match what was tested.",[37,1341,1343],{"id":1342},"step-5-call-it-from-the-scheduler","Step 5 — Call it from the scheduler",[10,1345,1346,1347,1350],{},"A scheduler only needs one line: run the project's command with the project's environment. ",[14,1348,1349],{},"uv run --project"," does that from any working directory:",[49,1352,1354],{"className":51,"code":1353,"language":53,"meta":54,"style":54},"# crontab: Monday 07:00\n0 7 * * 1  \u002Fhome\u002Freports\u002F.local\u002Fbin\u002Fuv run --project \u002Fsrv\u002Fweekly-report --no-dev weekly-report --out \u002Fsrv\u002Fshare\n",[14,1355,1356,1362],{"__ignoreMap":54},[58,1357,1358],{"class":60,"line":61},[58,1359,1361],{"class":1360},"s-wDw","# crontab: Monday 07:00\n",[58,1363,1364,1366,1369,1372,1374,1377,1380,1382,1385,1388,1390,1392,1395],{"class":60,"line":319},[58,1365,110],{"class":64},[58,1367,1368],{"class":68}," 7",[58,1370,1371],{"class":68}," *",[58,1373,1371],{"class":68},[58,1375,1376],{"class":68}," 1",[58,1378,1379],{"class":82},"  \u002Fhome\u002Freports\u002F.local\u002Fbin\u002Fuv",[58,1381,1127],{"class":82},[58,1383,1384],{"class":68}," --project",[58,1386,1387],{"class":82}," \u002Fsrv\u002Fweekly-report",[58,1389,1215],{"class":68},[58,1391,310],{"class":82},[58,1393,1394],{"class":68}," --out",[58,1396,1397],{"class":82}," \u002Fsrv\u002Fshare\n",[10,1399,1400,1401,1404,1405,1408,1409,1413,1414,1416,1417,1420],{},"On Windows, Task Scheduler's action is ",[14,1402,1403],{},"uv.exe"," with the same arguments; on macOS, a launchd ",[14,1406,1407],{},"ProgramArguments"," list — see ",[31,1410,1412],{"href":1411},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-a-python-excel-script-on-macos-with-launchd\u002F","Schedule a Python Excel Script on macOS with launchd",". Give the full path to ",[14,1415,65],{}," itself, because schedulers do not load shell profiles. In a Dockerfile, copy the uv binary from its official image and run ",[14,1418,1419],{},"uv sync --locked --no-dev"," in a layer before copying the source, so dependency layers are cached between builds.",[88,1422,97,1427,97,1430,97,1433,97,1436,97,1441,97,1446,97,1451,97,1455,97,1459,97,1465,97,1471,97,1475,97,1478,97,1481,97,1484,97,1489,97,1494,97,1498,97,1501,97,1503,97,1506,97,1512,97,1517,97,1521,97,1524,97,1526,97,1529,97,1534,97,1539,97,1542,97,1545],{"viewBox":1423,"role":91,"ariaLabelledBy":1424,"xmlns":95,"style":96},"4 0 752 436",[1425,1426],"uv-callers-t","uv-callers-d",[99,1428,1429],{"id":1425},"One locked environment, every way of running it",[103,1431,1432],{"id":1426},"The locked project environment fans in from a developer's uv run, the test suite, a cron line using uv run --project, and a container image built with uv sync --locked; all of them use the same package versions.",[107,1434],{"x":109,"y":110,"width":111,"height":1435,"fill":113},"436",[107,1437],{"x":1438,"y":1439,"width":1440,"height":257,"rx":120,"fill":165,"stroke":156,"style":123},"520.0","171.5","220.0",[125,1442,1444],{"transform":1443},"translate(618.0,184.5) scale(1.000)",[129,1445],{"d":548,"fill":132,"stroke":172,"style":134},[136,1447,1450],{"x":1448,"y":1449,"style":177},"630.0","231.0","uv.lock environment",[136,1452,1454],{"x":1448,"y":1453,"style":145},"250.5","exact versions everywhere",[107,1456],{"x":116,"y":117,"width":1457,"height":1458,"rx":120,"fill":121,"stroke":122,"style":123},"250.0","92.0",[125,1460,1462],{"transform":1461},"translate(133.0,29.0) scale(1.000)",[129,1463],{"d":1464,"fill":132,"stroke":133,"style":134},"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M4 21c0-4 3.6-7 8-7s8 3 8 7",[136,1466,1470],{"x":1467,"y":1468,"style":1469},"145.0","75.0","font-size:13px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","developer laptop",[136,1472,1176],{"x":1467,"y":1473,"style":1474},"94.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle",[129,1476],{"d":1477,"fill":132,"stroke":156,"style":123},"M274.0,62.0 C394.0,62.0 394.0,218.0 510.0,218.0",[158,1479],{"points":1480,"fill":161},"514.0,218.0 505.0,223.0 505.0,213.0",[107,1482],{"x":116,"y":1483,"width":1457,"height":1458,"rx":120,"fill":215,"stroke":216,"style":123},"120.0",[125,1485,1487],{"transform":1486},"translate(133.0,133.0) scale(1.000)",[129,1488],{"d":222,"fill":132,"stroke":223,"style":134},[136,1490,1493],{"x":1467,"y":1491,"style":1492},"179.0","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","tests and CI",[136,1495,1497],{"x":1467,"y":1496,"style":1474},"198.0","uv run pytest, sync --locked",[129,1499],{"d":1500,"fill":132,"stroke":156,"style":123},"M274.0,166.0 C394.0,166.0 394.0,218.0 510.0,218.0",[158,1502],{"points":1480,"fill":161},[107,1504],{"x":116,"y":1505,"width":1457,"height":1458,"rx":120,"fill":241,"stroke":242,"style":123},"224.0",[125,1507,1509],{"transform":1508},"translate(133.0,237.0) scale(1.000)",[129,1510],{"d":1511,"fill":132,"stroke":249,"style":134},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2",[136,1513,1516],{"x":1467,"y":1514,"style":1515},"283.0","font-size:13px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","cron \u002F Task Scheduler",[136,1518,1520],{"x":1467,"y":1519,"style":1474},"302.0","uv run --project …",[129,1522],{"d":1523,"fill":132,"stroke":156,"style":123},"M274.0,270.0 C394.0,270.0 394.0,218.0 510.0,218.0",[158,1525],{"points":1480,"fill":161},[107,1527],{"x":116,"y":1528,"width":1457,"height":1458,"rx":120,"fill":165,"stroke":156,"style":123},"328.0",[125,1530,1532],{"transform":1531},"translate(133.0,341.0) scale(1.000)",[129,1533],{"d":171,"fill":132,"stroke":172,"style":134},[136,1535,1538],{"x":1467,"y":1536,"style":1537},"387.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","container image",[136,1540,1419],{"x":1467,"y":1541,"style":1474},"406.0",[129,1543],{"d":1544,"fill":132,"stroke":156,"style":123},"M274.0,374.0 C394.0,374.0 394.0,218.0 510.0,218.0",[158,1546],{"points":1480,"fill":161},[37,1548,1550],{"id":1549},"updating-dependencies-deliberately","Updating dependencies deliberately",[10,1552,1553,1554,1557,1558,1560,1561,1563,1564,1568],{},"Upgrades become a decision rather than an accident: ",[14,1555,1556],{},"uv lock --upgrade-package pandas"," moves one package to its newest allowed version and updates the lock file, ",[14,1559,1180],{}," checks the report still works, and the diff of ",[14,1562,387],{}," in the pull request shows exactly what changed. For reports whose output is compared against a golden file — ",[31,1565,1567],{"href":1566},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcompare-excel-output-to-a-golden-file-in-tests\u002F","Compare Excel Output to a Golden File in Tests"," — that test is what catches a library upgrade that changes formatting or rounding.",[37,1570,1572],{"id":1571},"performance-notes","Performance notes",[10,1574,1575,1576,1578],{},"uv resolves and installs packages from a shared cache, so creating a second project with pandas and openpyxl, or rebuilding a CI environment, takes seconds rather than minutes; the example project above was set up and tested in well under a minute including downloads. ",[14,1577,28],{}," checks the environment against the lock file on every call, which costs milliseconds when nothing has changed. On CI, cache uv's cache directory between runs for the biggest speed-up.",[37,1580,1582],{"id":1581},"common-pitfalls","Common pitfalls",[1584,1585,1586,1598,1609,1622,1631],"ul",{},[1587,1588,1589,1595,1596,35],"li",{},[1590,1591,1592,1593,35],"strong",{},"Not committing ",[14,1594,387],{}," Without it every machine resolves its own versions; commit it with ",[14,1597,383],{},[1587,1599,1600,1608],{},[1590,1601,1602,1604,1605,1607],{},[14,1603,254],{}," without ",[14,1606,258],{}," on servers."," It may update the lock file; production should fail instead.",[1587,1610,1611,1614,1615,1618,1619,1621],{},[1590,1612,1613],{},"Development tools in production."," Use ",[14,1616,1617],{},"--dev"," groups and ",[14,1620,1255],{}," when deploying.",[1587,1623,1624,1627,1628,1630],{},[1590,1625,1626],{},"Relying on the system Python."," Pin the version with ",[14,1629,358],{}," so the report does not change when the OS upgrades Python.",[1587,1632,1633,1636],{},[1590,1634,1635],{},"Schedulers without a full path to uv."," They do not load shell profiles; use the absolute path.",[37,1638,1640],{"id":1639},"conclusion","Conclusion",[10,1642,1643,1644,1647,1648,1650,1651,1654,1655,1657,1658,1660,1661,1663,1664,1666,1667,1669,1670,1672],{},"Create the report as a uv package with ",[14,1645,1646],{},"uv init --package --python 3.12",", add its libraries with ",[14,1649,24],{}," and its test tools with ",[14,1652,1653],{},"uv add --dev",", and commit ",[14,1656,383],{}," and ",[14,1659,387],{},". Expose the report as a ",[14,1662,204],{}," command, run it and its tests with ",[14,1665,28],{},", install exactly the locked versions elsewhere with ",[14,1668,1419],{},", and point every scheduler at ",[14,1671,1349],{},". Upgrade packages one at a time, with the tests as the gate.",[37,1674,1676],{"id":1675},"frequently-asked-questions","Frequently asked questions",[10,1678,1679,1682],{},[1590,1680,1681],{},"How do I start a Python project with uv?","\nRun uv init --package --python 3.12 my-project, then uv add for each dependency. uv creates pyproject.toml, a .python-version pin, a virtual environment and a uv.lock file with exact versions.",[10,1684,1685,1688],{},[1590,1686,1687],{},"What is the difference between pyproject.toml and uv.lock?","\npyproject.toml lists the packages the project depends on, usually with minimum versions. uv.lock records the exact resolved version and hash of every package, including indirect ones, so every machine installs the same set.",[10,1690,1691,1694],{},[1590,1692,1693],{},"How do I run a uv project from cron?","\nUse the full path to uv with --project pointing at the project folder, for example \u002Fhome\u002Fuser\u002F.local\u002Fbin\u002Fuv run --project \u002Fsrv\u002Freport --no-dev report-command. uv uses the project's locked environment without any activation.",[10,1696,1697,1700],{},[1590,1698,1699],{},"How do I make sure a server installs exactly the tested versions?","\nRun uv sync --locked --no-dev. It installs the versions in uv.lock and fails if the lock file is out of date with pyproject.toml instead of resolving new versions.",[37,1702,1704],{"id":1703},"related","Related",[1584,1706,1707,1713,1720,1727],{},[1587,1708,1709,1710,1712],{},"Up one level: ",[31,1711,34],{"href":33}," — making report code dependable.",[1587,1714,1715,1719],{},[31,1716,1718],{"href":1717},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse\u002F","Build a Command-Line Tool for Excel Reports with argparse"," — the command this project exposes.",[1587,1721,1722,1726],{},[31,1723,1725],{"href":1724},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller\u002F","Package a Python Excel Script as an EXE with PyInstaller"," — for users without Python at all.",[1587,1728,1729,1733],{},[31,1730,1732],{"href":1731},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-docker\u002F","Run a Python Excel Report in Docker"," — the container route for the same project.",[1735,1736,1737],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .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 .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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":54,"searchDepth":319,"depth":319,"links":1739},[1740,1741,1742,1743,1744,1745,1746,1747,1748,1749,1750,1751],{"id":39,"depth":319,"text":40},{"id":264,"depth":319,"text":265},{"id":370,"depth":319,"text":371},{"id":568,"depth":319,"text":569},{"id":1187,"depth":319,"text":1188},{"id":1342,"depth":319,"text":1343},{"id":1549,"depth":319,"text":1550},{"id":1571,"depth":319,"text":1572},{"id":1581,"depth":319,"text":1582},{"id":1639,"depth":319,"text":1640},{"id":1675,"depth":319,"text":1676},{"id":1703,"depth":319,"text":1704},"2026-09-29","Turn a loose report script into a reproducible project with uv: a pinned Python version, dependencies and a lock file, a console command for the report, a test that runs with one command, locked installs on servers and in CI, and a scheduler entry that always runs the exact environment that was tested.","md",[1756,1758,1760,1762],{"q":1681,"a":1757},"Run uv init --package --python 3.12 my-project, then uv add for each dependency. uv creates pyproject.toml, a .python-version pin, a virtual environment and a uv.lock file with exact versions.",{"q":1687,"a":1759},"pyproject.toml lists the packages the project depends on, usually with minimum versions. uv.lock records the exact resolved version and hash of every package, including indirect ones, so every machine installs the same set.",{"q":1693,"a":1761},"Use the full path to uv with --project pointing at the project folder, for example \u002Fhome\u002Fuser\u002F.local\u002Fbin\u002Fuv run --project \u002Fsrv\u002Freport --no-dev report-command. uv uses the project's locked environment without any activation.",{"q":1699,"a":1763},"Run uv sync --locked --no-dev. It installs the versions in uv.lock and fails if the lock file is out of date with pyproject.toml instead of resolving new versions.","set up an excel automation project with uv",null,{"breadcrumb":1767},[1768,1770,1773],{"name":1769,"item":736},"Home",{"name":1771,"item":1772},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":34,"item":33},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv",{"title":1776,"description":1777},"Set Up a Python Excel Automation Project with uv","Create a reproducible Python Excel report project with uv: uv init --package, uv add pandas openpyxl xlsxwriter, uv.lock, [project.scripts] commands, uv run pytest, uv sync --locked for servers and CI, and uv run in cron or Task Scheduler.","set-up-an-excel-automation-project-with-uv","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv\u002Findex",[1781],"reliable-jobs","how-to","tzg3iqoeFm85LAenf85fBNeSXWQHtYAZleOrRC-CUjg",[1785,1789],{"title":1786,"path":1787,"stem":1788,"children":-1},"Profile a Slow Excel Report Script in Python","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fprofile-a-slow-excel-report-script","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fprofile-a-slow-excel-report-script\u002Findex",{"title":1790,"path":1791,"stem":1792,"children":-1},"Store Credentials for Excel Report Scripts Safely","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-credentials-for-excel-report-scripts-safely","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-credentials-for-excel-report-scripts-safely\u002Findex",[1794,1796],{"path":1795,"title":1771},"\u002Fautomating-reporting-workflows",{"path":1797,"title":34},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts",[],[1800,1804,1807,1810,1813,1816],{"path":1801,"title":1802,"kind":1803},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation","Error Handling and Logging in Excel Automation","guide",{"path":1805,"title":1806,"kind":1782},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run","Detect a Scheduled Report That Did Not Run",{"path":1808,"title":1809,"kind":1782},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fignore-excel-lock-files-when-processing-a-folder","Ignore Excel Lock Files When Processing a Folder",{"path":1811,"title":1812,"kind":1782},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file","Log Python Excel Script Output to a File",{"path":1814,"title":1815,"kind":1782},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun","Make an Excel Report Job Safe to Rerun",{"path":1817,"title":1818,"kind":1782},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports","Prevent Formula Injection in Excel Exports from Python",1790972142797]