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 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
--helpin every script. A shortREADMEthat 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.pymeans 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?
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
jqone 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_TOKENfrom 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=Falsehides Typer's completion options, which make little sense for a script not installed as a command.
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.