春江暮客

春江暮客的个人学习分享网站

Write Self-Contained Python Scripts with uv and PEP 723

2026-06-02 Technology
Write Self-Contained Python Scripts with uv and PEP 723

Many automation scripts start as a small tools.py file, then slowly gain dependencies such as requests, rich, or pandas. The problem appears when you share the script: the other machine does not know which packages to install, and a server run may require a virtual environment first.

uv supports PEP 723 inline script metadata, which lets you write the Python version and dependencies at the top of a .py file. This tutorial uses a runnable daily_report.py example to make a script that can be copied and run directly.

By the end, you will have:

  1. A Python single-file script with inline dependencies
  2. One uv run daily_report.py command
  3. An optional script lockfile
  4. Direct fixes for common errors

When this is useful

This workflow is a good fit for small, independent scripts:

  1. Log analysis, CSV cleanup, and data reports
  2. Server checks, cron jobs, and one-off migrations
  3. Small tools shared with teammates
  4. Automation scripts that do not need a full project directory

If your script has grown into a multi-module application, or if you need to publish it to PyPI, keep using pyproject.toml and a normal uv project. Single-file scripts are useful because they are lightweight and easy to share, not because they replace every project layout.

Method 1: Run with a temporary dependency

Start with the simplest option: do not edit the script, but tell uv which packages are needed at runtime.

Create hello_rich.py:

from rich.console import Console

console = Console()
console.print("[green]hello from uv[/green]")

Running it directly may fail because rich is not installed in the system environment:

python3 hello_rich.py

A common error looks like this:

ModuleNotFoundError: No module named 'rich'

Use uv run --with to create an isolated temporary environment and run the script:

uv run --with rich hello_rich.py

This is useful for quick tests. If the script should be saved or shared, it is better to write the dependencies inside the script itself.

Method 2: Put dependencies inside the script

Now build a more complete example: read tasks.csv, count tasks by status, and print a table.

1. Prepare uv

If uv is not installed yet, run:

curl -LsSf https://astral.sh/uv/install.sh | sh

Reopen your terminal and check:

uv --version
uv help

2. Create the script file

Initialize a file with script metadata:

uv init --script daily_report.py --python 3.12

Add the dependencies required by this script:

uv add --script daily_report.py rich python-dateutil

After the command runs, uv writes a metadata block like this at the top of daily_report.py:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "python-dateutil",
#   "rich",
# ]
# ///

This block is the core of PEP 723: the dependencies travel with the script, so you no longer need to send a separate requirements.txt.

Implementation: Write daily_report.py

Replace daily_report.py with the complete file below:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "python-dateutil",
#   "rich",
# ]
# ///

from __future__ import annotations

import argparse
import csv
from collections import Counter
from pathlib import Path

from dateutil.parser import parse
from rich.console import Console
from rich.table import Table


console = Console()


def load_tasks(path: Path) -> list[dict[str, str]]:
    with path.open(newline="", encoding="utf-8") as file:
        return list(csv.DictReader(file))


def build_table(tasks: list[dict[str, str]]) -> Table:
    status_counts = Counter(task["status"] for task in tasks)
    due_dates = [parse(task["due"]) for task in tasks if task.get("due")]
    next_due = min(due_dates).date().isoformat() if due_dates else "n/a"

    table = Table(title="Daily Task Report")
    table.add_column("Metric")
    table.add_column("Value", justify="right")
    table.add_row("Total tasks", str(len(tasks)))
    table.add_row("Open", str(status_counts["open"]))
    table.add_row("Doing", str(status_counts["doing"]))
    table.add_row("Done", str(status_counts["done"]))
    table.add_row("Next due date", next_due)
    return table


def main() -> None:
    parser = argparse.ArgumentParser(description="Build a small task report.")
    parser.add_argument("csv_file", type=Path, help="CSV file with title,status,due columns")
    args = parser.parse_args()

    tasks = load_tasks(args.csv_file)
    console.print(build_table(tasks))


if __name__ == "__main__":
    main()

Prepare sample data:

cat > tasks.csv <<'EOF'
title,status,due
Write blog,done,2026-06-02
Check server,open,2026-06-03
Clean logs,doing,2026-06-04
Update docs,open,2026-06-05
EOF

Run the script:

uv run daily_report.py tasks.csv

On the first run, uv reads the dependencies from the script header, creates an isolated environment, and executes the script. Later runs can reuse the cache and will usually be much faster.

Validate the output

The output should look similar to this:

     Daily Task Report
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ Metric        ┃      Value ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ Total tasks   │          4 │
│ Open          │          2 │
│ Doing         │          1 │
│ Done          │          1 │
│ Next due date │ 2026-06-02 │
└───────────────┴────────────┘

You can also inspect the dependency tree for this script:

uv tree --script daily_report.py

If the script will be used in production or a scheduled job, generate a lockfile:

uv lock --script daily_report.py

This creates a file next to the script:

daily_report.py.lock

Commit the lockfile with the script when you want future runs to use reproducible dependency versions.

Make it executable

If you want to run ./daily_report.py directly, add this shebang as the first line:

#!/usr/bin/env -S uv run --script

The start of the full file should look like this:

#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "python-dateutil",
#   "rich",
# ]
# ///

Then make it executable:

chmod +x daily_report.py
./daily_report.py tasks.csv

This is convenient for scripts in ~/bin, project scripts/ directories, CI jobs, and server health checks.

Troubleshooting

ModuleNotFoundError

The usual cause is running python daily_report.py directly and bypassing uv.

Fix it with:

uv run daily_report.py tasks.csv

If the script is missing another dependency, add it to the metadata:

uv add --script daily_report.py package-name

Python version mismatch

If the script contains:

# requires-python = ">=3.12"

but the machine does not have a compatible version, install it first:

uv python install 3.12
uv run daily_report.py tasks.csv

Dependencies look wrong inside a project directory

A script with inline metadata uses its own dependencies instead of the current project’s dependencies. That makes the script more independent. If it truly needs project code, it should probably become a normal project command instead of a single-file script.

Permission denied

If ./daily_report.py fails with a permission error:

chmod +x daily_report.py

If your system does not support env -S, use the explicit command:

uv run --script daily_report.py tasks.csv

Summary

uv plus PEP 723 is a practical way to manage small Python automation scripts: dependencies live in the file, and uv creates the isolated environment at runtime. For scripts that need to be shared, scheduled, or copied onto a server, this is lighter than manually maintaining virtual environments and requirements.txt. In real use, add dependencies with uv add --script, then lock production scripts with uv lock --script.

友情链接

其它