Set Up an Excel Automation Project with uv
Most report scripts start life as report.py next to whatever Python happened to be installed, with packages added by 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, uv add records dependencies and resolves them into a lock file, and uv run executes the report in exactly that environment everywhere. This page is part of Testing and Packaging Excel Automation Scripts.
Prerequisites
Install uv once per machine — from the installer script on astral.sh, 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.
uv --version | cut -d' ' -f1-2
Step 1 — Create the project
uv init --package creates a project laid out as an installable package, with its code under src/, which is what makes a proper command for the report possible later. Pin the Python version the report is tested with:
uv init --package --python 3.12 --author-from none --vcs none weekly-report > /dev/null
cd weekly-report && cat .python-version && find . -type f | sort
.python-version pins the interpreter: uv uses — or downloads — Python 3.12 for this project whatever the machine's default is. --author-from none keeps names and email addresses out of the generated metadata; --vcs none skips creating a git repository, which you would normally want, and is only omitted here to keep the listing short.
Step 2 — Add the dependencies
uv add installs packages into the project's own .venv, records them in pyproject.toml and resolves the complete set of versions into uv.lock. Test tools go into a separate development group:
cd weekly-report
uv add --quiet pandas openpyxl xlsxwriter
uv add --quiet --dev pytest
sed -n '/^dependencies/,/^]/p;/^\[dependency-groups\]/,/^]/p' pyproject.toml
grep -c '^\[\[package\]\]' uv.lock | xargs echo "packages in uv.lock:"
pyproject.toml lists what the project asked for, with a lower bound; uv.lock 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. Make Excel Output Reproducible covers the rest of reproducibility — fixed dates, sorted output, stable styles.
Step 3 — Make the report a command
The generated package has a main() function registered as a command in [project.scripts]. Put the report there — a small one here — and a test beside it:
import argparse
from datetime import date
from pathlib import Path
import pandas as pd
def build(report_date: date, out_dir: Path) -> Path:
df = pd.DataFrame({"Region": ["North", "South", "East", "West"], "Sales": [482100, 391250, 297800, 259000]})
out = out_dir / f"weekly_sales_{report_date:%Y-%m-%d}.xlsx"
out_dir.mkdir(parents=True, exist_ok=True)
df.sort_values("Sales", ascending=False).to_excel(out, index=False)
return out
def main() -> None:
p = argparse.ArgumentParser(prog="weekly-report")
p.add_argument("--date", type=date.fromisoformat, default=date.today())
p.add_argument("--out", type=Path, default=Path("reports"))
args = p.parse_args()
print(build(args.date, args.out))
from datetime import date
import pandas as pd
from weekly_report import build
def test_build_sorts_regions(tmp_path):
out = build(date(2026, 9, 28), tmp_path)
df = pd.read_excel(out)
assert out.name == "weekly_sales_2026-09-28.xlsx"
assert df["Region"].tolist()[0] == "North"
assert df["Sales"].is_monotonic_decreasing
cd weekly-report
grep -A1 '^\[project.scripts\]' pyproject.toml
uv run --quiet weekly-report --date 2026-09-28
uv run --quiet pytest -q -p no:cacheprovider tests 2>&1 | tail -1
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. uv run pytest does the same for the tests, with the development group included. The first uv run after cloning the repository sets everything up; later ones start instantly.
Step 4 — Install exactly the locked versions elsewhere
On a server, a scheduled job's machine or a CI runner, install from the lock file and refuse to change it:
cd weekly-report
uv sync --locked --no-dev --quiet && echo "server environment matches uv.lock"
uv lock --check --quiet && echo "lock file is up to date with pyproject.toml"
--locked fails if pyproject.toml has changed without the lock file being updated — the classic "works on my machine" gap — instead of silently resolving new versions. --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 pip install -r:
cd weekly-report
uv export --format requirements-txt --no-dev --no-emit-project --quiet > requirements.txt
grep -c '==' requirements.txt | xargs echo "pinned packages:"
grep -m1 '^pandas==' requirements.txt
Every package appears with an exact == version and --hash lines, so pip refuses to install anything that does not match what was tested.
Step 5 — Call it from the scheduler
A scheduler only needs one line: run the project's command with the project's environment. uv run --project does that from any working directory:
# crontab: Monday 07:00
0 7 * * 1 /home/reports/.local/bin/uv run --project /srv/weekly-report --no-dev weekly-report --out /srv/share
On Windows, Task Scheduler's action is uv.exe with the same arguments; on macOS, a launchd ProgramArguments list — see Schedule a Python Excel Script on macOS with launchd. Give the full path to uv itself, because schedulers do not load shell profiles. In a Dockerfile, copy the uv binary from its official image and run uv sync --locked --no-dev in a layer before copying the source, so dependency layers are cached between builds.
Updating dependencies deliberately
Upgrades become a decision rather than an accident: uv lock --upgrade-package pandas moves one package to its newest allowed version and updates the lock file, uv run pytest checks the report still works, and the diff of uv.lock in the pull request shows exactly what changed. For reports whose output is compared against a golden file — Compare Excel Output to a Golden File in Tests — that test is what catches a library upgrade that changes formatting or rounding.
Performance notes
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. uv run 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.
Common pitfalls
- Not committing
uv.lock. Without it every machine resolves its own versions; commit it withpyproject.toml. uv syncwithout--lockedon servers. It may update the lock file; production should fail instead.- Development tools in production. Use
--devgroups and--no-devwhen deploying. - Relying on the system Python. Pin the version with
.python-versionso the report does not change when the OS upgrades Python. - Schedulers without a full path to uv. They do not load shell profiles; use the absolute path.
Conclusion
Create the report as a uv package with uv init --package --python 3.12, add its libraries with uv add and its test tools with uv add --dev, and commit pyproject.toml and uv.lock. Expose the report as a [project.scripts] command, run it and its tests with uv run, install exactly the locked versions elsewhere with uv sync --locked --no-dev, and point every scheduler at uv run --project. Upgrade packages one at a time, with the tests as the gate.
Frequently asked questions
How do I start a Python project with uv? 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.
What is the difference between pyproject.toml and uv.lock? 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.
How do I run a uv project from cron? Use the full path to uv with --project pointing at the project folder, for example /home/user/.local/bin/uv run --project /srv/report --no-dev report-command. uv uses the project's locked environment without any activation.
How do I make sure a server installs exactly the tested versions? 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.
Related
- Up one level: Testing and Packaging Excel Automation Scripts — making report code dependable.
- Build a Command-Line Tool for Excel Reports with argparse — the command this project exposes.
- Package a Python Excel Script as an EXE with PyInstaller — for users without Python at all.
- Run a Python Excel Report in Docker — the container route for the same project.
More on logging, testing & packaging
- GuideError Handling and Logging in Excel Automation
- How-toDetect a Scheduled Report That Did Not Run
- How-toIgnore Excel Lock Files When Processing a Folder
- How-toLog Python Excel Script Output to a File
- How-toMake an Excel Report Job Safe to Rerun
- How-toPrevent Formula Injection in Excel Exports from Python