Single-file scripts with dependencies

Intermediate25 min

Declare a script's Python version and dependencies in a comment block at the top of the file, and run it with uv without creating a project or environment yourself.

What you will learn
  • Write a PEP 723 "# /// script" metadata block
  • Run a script that has dependencies with uv run
  • Read the metadata block from Python with tomllib
  • Decide when a script should become a project
On this page

A useful script of forty lines should not need a project folder, a pyproject.toml and a virtual environment. Yet as soon as it imports a third-party package, it needs all three, or a note that says “first run pip install humanize”. PEP 723 fixes this by letting the script carry its own requirements in a specially formatted comment.

The metadata block

Here is a script that prints file sizes in a human-readable way, using the humanize package:

sizes.py
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "humanize",
# ]
# ///
import sys
import humanize
for arg in sys.argv[1:]:
print(humanize.naturalsize(int(arg)))

The block starts at a line that is exactly # /// script and ends at a line that is exactly # ///. Every line in between starts with #, and the text, once the # prefix is removed, is TOML. The two keys you will use most are the ones from pyproject.toml: requires-python and dependencies. For Python itself the block is a comment, so a plain interpreter ignores it.

Running it with uv

Tools that understand the block can read it, make a temporary environment with the right packages, and run the script in it. uv run does this:

Terminal window
$ uv run sizes.py 1500 3000000
Installed 1 package in ...
1.5 kB
3.0 MB

There was no venv, no pip and no activation. The environment is cached, so the second run starts immediately, without the install line:

Terminal window
$ uv run sizes.py 1500
1.5 kB

pipx run sizes.py reads the same block. Because the block is a standard, any tool can support it.

Running the same file with plain python shows what the block does and does not do. It declares requirements, it does not install them:

Terminal window
$ python sizes.py 5
Traceback (most recent call last):
File "sizes.py", line 9, in <module>
import humanize
ModuleNotFoundError: No module named 'humanize'

To add a dependency without editing the comment by hand, uv can do it for you:

Terminal window
$ uv add --script sizes.py rich

On macOS and Linux you can go one step further and make the script directly executable. Put this as the very first line, above the metadata block, mark the file executable with chmod +x sizes.py, and run it as ./sizes.py:

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

The block is just TOML

Because the content is TOML, you can read it with tomllib from the lesson before. This script reads its own block, which shows exactly what a tool sees:

read_metadata.py
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "humanize",
# "rich>=13",
# ]
# ///
import tomllib
from pathlib import Path
def read_script_block(source):
"""Return the TOML inside the '# /// script' block as a dict, or None."""
lines = source.splitlines()
if "# /// script" not in lines:
return None
start = lines.index("# /// script") + 1
body = []
for line in lines[start:]:
if line == "# ///":
break
body.append(line.removeprefix("# ").removeprefix("#"))
return tomllib.loads("\n".join(body))
metadata = read_script_block(Path(__file__).read_text(encoding="utf-8"))
print("requires-python:", metadata["requires-python"])
for requirement in metadata["dependencies"]:
print("needs", requirement)
Output
requires-python: >=3.11
needs humanize
needs rich>=13

This version is deliberately simple. It ignores details the standard allows, such as several blocks in one file with different types, and it is meant for understanding, not for production. A real tool uses a stricter parser.

When to stop using a script

Inline metadata suits one file that you run, share by email, or keep in a bin folder. Once you have two files that import each other, tests, or a command you want to install for other people, you are describing a project, and the pyproject.toml of the previous lesson is the right tool. The move is mechanical, because the keys are the same.

ExerciseSpot the broken block

Which of these blocks will not work as intended, and why?

Expected: one line per block, in order, reading works or broken: <symptom>.

# /// script
# dependencies = ["requests"]
# ///
# /// script
dependencies = ["requests"]
# ///
#/// script
# dependencies = ["requests"]
# ///
Show solution

The second and the third.

In the second, the middle line does not start with #, so the block has no valid end. Every line between the opening and closing tags must be a comment, and uv reports an opening tag without a closing tag.

In the third, the opening line is #/// script, with no space after #, so it does not match the required # /// script. Nothing complains: the block is not recognised, no dependencies are installed, and the script fails later with ModuleNotFoundError.

The first is valid. requires-python is optional.

ExerciseAdd a metadata block to a script

Here is a script that works when humanize is installed. Add a metadata block that declares the dependency and a minimum Python of 3.11, so that uv run can run it from a clean machine. It should print 1,234,567.

commas.py
import humanize
print(humanize.intcomma(1234567))
Show solution
commas.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["humanize"]
# ///
import humanize
print(humanize.intcomma(1234567))
Terminal window
$ uv run commas.py
1,234,567

A list on one line is fine in TOML. The block must come before the first line of code that is not a comment or blank line, which here is the import.

ExerciseA script with a dependency of your choice

Write a script called shout.py that takes words from the command line and prints them with rich markup, for example uv run shout.py hello world printing hello world in bold. Declare rich in the metadata, and add requires-python. Run it with uv. Then run it with plain python outside any environment where rich is installed and read the error.

Expected: uv run shout.py hello world prints hello world in bold, and the last line of the plain python run is:

ModuleNotFoundError: No module named 'rich'
Show solution
shout.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["rich"]
# ///
import sys
from rich import print
print("[bold]" + " ".join(sys.argv[1:]) + "[/bold]")
Terminal window
$ uv run shout.py hello world
hello world
$ python shout.py hello world
ModuleNotFoundError: No module named 'rich'

The first command prints the words in bold in a colour-capable terminal. The second shows that the block declares requirements and does not install them: only a tool that reads it, such as uv or pipx, builds the environment.

Summary

  • A # /// script comment block at the top of a file holds TOML with requires-python and dependencies (PEP 723).
  • uv run script.py (or pipx run script.py) builds a cached environment from the block and runs the script. Plain python ignores it.
  • Move to a pyproject.toml project when one file becomes several, or when you want tests and installable commands.