Project Setup

Running One-Off CLI Scripts with uv run and PEP 723

Write single-file Python CLI scripts that declare their own dependencies with PEP 723 inline metadata, run anywhere with uv run, lock them, and graduate them later.

Updated

Not every command-line tool deserves a project. A script that syncs labels between two issue trackers, one that audits S3 bucket policies once a quarter, a migration helper you will run three times — these start as a single .py file, and they immediately hit the same problem: they need httpx or rich or boto3, and the person running them does not have those installed in the right environment. The traditional answers — a README saying "pip install these first", a requirements.txt next to the script, a virtual environment someone has to create — all fail as soon as the script is shared. PEP 723 lets a script declare its own Python version and dependencies in a comment block at the top, and uv run reads that block, provisions a cached environment and runs the script. This guide shows how to write such scripts as proper little CLIs, lock them for reproducibility, make them directly executable, and recognise when one should graduate to a real project. It belongs to the uv for Python CLI dependency management topic.

Prerequisites

  • uv installed. Nothing else: uv downloads a suitable Python if the machine lacks one.
  • A task small enough for one file.

Anatomy of a self-describing script

A self-describing script A Python script with a PEP 723 inline metadata block declaring the required Python version and dependencies, followed by ordinary code. A self-describing script sync_tags.py one file # /// script start of the block requires-python = ">=3.11" interpreter dependencies = ["httpx"] installed on demand # /// end, then normal code uv run sync_tags.py handles the rest Cached environments, near-instant reruns Lock with uv lock --script The script carries its own requirements, so sharing it means sharing one file.

A PEP 723 block is a TOML document inside comments, between # /// script and # ///. It supports two keys: requires-python and dependencies, with the same syntax as pyproject.toml. Tools that understand the standard — uv, pipx, Hatch, PDM — read it; to Python itself it is just a comment, so the script still runs normally in an environment that already has the dependencies.

The recipe

Create the skeleton and add dependencies with uv, which edits the block for you:

uv init --script sync_labels.py --python 3.11
uv add --script sync_labels.py httpx typer rich

Then write the script as a real CLI, with arguments, help text and exit codes — one-off scripts have a way of being run again next year by someone else:

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "httpx>=0.28",
#     "rich>=13.7",
#     "typer>=0.12",
# ]
# ///
"""Copy issue labels from one GitHub repository to another."""
from __future__ import annotations

import os

import httpx
import typer
from rich.console import Console

app = typer.Typer(add_completion=False)
err = Console(stderr=True)


def client() -> httpx.Client:
    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        err.print("[red]error:[/red] set GITHUB_TOKEN")
        raise typer.Exit(2)
    return httpx.Client(base_url="https://api.github.com", timeout=20.0,
                        headers={"Authorization": f"Bearer {token}",
                                 "Accept": "application/vnd.github+json"})


@app.command()
def main(
    source: str = typer.Argument(..., help="owner/repo to copy labels from"),
    target: str = typer.Argument(..., help="owner/repo to copy labels to"),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would change."),
) -> None:
    """Copy labels from SOURCE to TARGET, creating any that are missing."""
    with client() as gh:
        wanted = {l["name"]: l for l in gh.get(f"/repos/{source}/labels", params={"per_page": 100}).json()}
        existing = {l["name"] for l in gh.get(f"/repos/{target}/labels", params={"per_page": 100}).json()}
        missing = sorted(set(wanted) - existing)
        for name in missing:
            label = wanted[name]
            err.print(f"{'would create' if dry_run else 'creating'} [bold]{name}[/bold]")
            if not dry_run:
                gh.post(f"/repos/{target}/labels", json={
                    "name": name, "color": label["color"], "description": label.get("description") or "",
                }).raise_for_status()
    err.print(f"{len(missing)} label(s) {'to create' if dry_run else 'created'}")


if __name__ == "__main__":
    app()

Run it:

uv run sync_labels.py acme/api acme/web --dry-run
./sync_labels.py acme/api acme/web          # after chmod +x, thanks to the shebang

The first run creates an environment with the declared dependencies in uv's cache; later runs reuse it and start almost instantly. Nothing is installed into the user's global Python or any project's .venv.

The shebang

#!/usr/bin/env -S uv run --script makes the file directly executable on Linux and macOS: the -S flag lets env pass multiple arguments, so the kernel runs uv run --script ./sync_labels.py .... Colleagues can put the script on their PATH and use it like any other command, and uv handles the environment invisibly. On Windows, uv run sync_labels.py works; the shebang is simply ignored.

Locking for reproducibility

The dependency block holds ranges, so two runs months apart may resolve different versions. When that matters — a script in a runbook, or one run in CI — lock it:

uv lock --script sync_labels.py      # writes sync_labels.py.lock next to the script

uv run then uses the locked versions whenever the lockfile is present and consistent with the block. Commit the lockfile alongside the script. An alternative that keeps everything in one file is exclude-newer in a [tool.uv] table inside the block, which restricts resolution to packages published before a date — handy for scripts that must behave the same in a year's time.

Sharing scripts across a team

Self-describing scripts change how a team can share small tools. Instead of a wiki page of "setup steps", a repository of scripts — ops-scripts/ with one file per task — becomes a toolbox anyone can use with nothing but uv installed. A few conventions keep such a collection healthy:

  • One task per file, named for the verb. rotate_keys.py, audit_buckets.py, sync_labels.py. The file name is the command name.
  • A docstring and --help in every script. A short README that lists each script with its one-line purpose is then easy to generate from the docstrings.
  • Lock scripts that touch production. A lockfile beside rotate_keys.py means the script run during an incident behaves exactly as it did when it was reviewed.
  • Review them like code. Scripts that act on real systems deserve the same pull-request review as the services they touch; the single-file format makes that review easy.
  • Retire them. A script nobody has run in a year is a liability. Delete it, or promote it into a maintained CLI if it turns out to matter.

When several scripts start sharing helper code, that is the clearest signal they want to become one CLI with subcommands.

Script or project?

Script or project? A decision between a single-file PEP 723 script and a packaged CLI project, based on whether the tool needs tests, multiple modules or distribution. Script or project? Will it need tests, several modules or other users installing it? No — one job, one file PEP 723 script uv run it Yes — it is becoming a tool A project uv init --package Graduating later is cheap: the inline dependencies become [project] dependencies.

PEP 723 scripts are ideal for tools that are one file, one job, run by a handful of people. Signs a script should become a project: it has grown past a few hundred lines or wants a second module; it needs tests you run in CI; other people want to install it as a command; or it needs data files. Graduating is straightforward: uv init --package, move the code into src/, and copy the dependency list into [project] dependencies — the syntax is the same. uv init vs Poetry init for CLI tools covers the project side.

UX considerations

  • Treat it as a real CLI. --help, --dry-run, clear errors and exit codes cost a few lines with Typer and save the next person from reading the source.
  • Put narration on stderr. Even a one-off script might be piped into jq one day; keep stdout for results. The reasoning is in working with stdin, stdout and pipes.
  • Read secrets from the environment. Never hard-code tokens in shared scripts; GITHUB_TOKEN from the environment is shown above, and the broader patterns are in reading secrets from env and files.
  • Disable what you do not need. add_completion=False hides Typer's completion options, which make little sense for a script not installed as a command.
Running and sharing a script Terminal output of adding a dependency to a script with uv, running it, and running a script directly from a URL. Running and sharing a script bash $ uv add --script sync_tags.py httpx rich Updated `sync_tags.py` $ uv run sync_tags.py --dry-run Installed 9 packages in 12ms would add 3 tags, remove 1 A shebang of #!/usr/bin/env -S uv run --script makes the file directly executable.

Testing the behaviour

Scripts that matter deserve a test or two. Because a PEP 723 script is importable Python, pytest can load it as a module, and uv run --with provides test dependencies without adding them to the script's own block:

# test_sync_labels.py
import importlib.util
from pathlib import Path

from typer.testing import CliRunner

spec = importlib.util.spec_from_file_location("sync_labels", Path(__file__).with_name("sync_labels.py"))
sync_labels = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sync_labels)


def test_missing_token_is_a_usage_error(monkeypatch):
    monkeypatch.delenv("GITHUB_TOKEN", raising=False)
    result = CliRunner().invoke(sync_labels.app, ["a/b", "c/d"])
    assert result.exit_code == 2
    assert "GITHUB_TOKEN" in result.output


def test_help_mentions_dry_run():
    result = CliRunner().invoke(sync_labels.app, ["--help"])
    assert "--dry-run" in result.output
uv run --with pytest --with-requirements sync_labels.py pytest test_sync_labels.py

--with-requirements sync_labels.py installs the script's own declared dependencies into the test environment, so the test runs against exactly what the script declares. For HTTP calls, the MockTransport techniques in building an API client CLI with httpx apply unchanged.

Conclusion

PEP 723 turns a one-off Python script into a self-contained, shareable tool: declare the Python version and dependencies in a comment block, run it with uv run, add a uv run --script shebang to make it executable, and lock it when reproducibility matters. Write it as a small real CLI from the start, and when it outgrows one file, graduating to a project is a copy-and-paste of the dependency list.

Frequently asked questions

Does pipx support PEP 723 scripts too?

Yes — pipx run script.py reads the same block. uv is faster and adds locking and script editing commands, but the file itself is portable between tools.

Where does uv keep the script environments?

In its cache directory, keyed by the script's dependencies. uv cache clean removes them; they are recreated on the next run.

Can a script depend on a private package index?

Yes, via a [tool.uv] table inside the block (for example index-url), or through environment variables such as UV_INDEX_URL. Credentials should come from the environment or keyring, never the script.

Can I run a script straight from a URL?

uv run https://example.com/script.py works, and the inline metadata is honoured. Only do this for sources you trust — it runs arbitrary code with your permissions.