Building a single-file portable Python script with embedded dependency management

2026-05-01 02:40 (6 months ago)
Pocket Hermit
Play a song themed on this article

When I want to write a "small script" in Python, it gets tedious as soon as I try to use an external library. I had to create a pyproject.toml, install the dependencies with uv add (or uv sync), set up a virtual environment too if possible... and so on.

There is a way to do all of this with just one Python file. It uses the combination of PEP 723 (Inline script metadata, accepted in 2024) and uv.

Final form

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["jinja2>=3.1", "requests>=2.31"]
# ///
"""Render a Jinja2 template fetched from a URL."""
import sys
import requests
from jinja2 import Template

resp = requests.get(sys.argv[1])
print(Template(resp.text).render(name="world"))

Just save this and run chmod +x script.py, and you can run it with

./script.py https://example.com/template.j2

No pip install or python -m venv is needed. Even if Python itself is not installed, uv downloads it automatically.

Line-by-line explanation

The shebang line

#!/usr/bin/env -S uv run --script
  • #! = the Unix shebang. The kernel looks at the start of the file and interprets it as "run it with this"
  • /usr/bin/env -S = the -S option of the env command. It means "the shebang arguments may be split into several by whitespace". Without it, uv run --script is interpreted as one executable name and you get command not found
    • Supported on macOS and Linux 4.0+
    • Does not work on old environments such as Ubuntu 18.04 and earlier
  • uv run --script = tells uv "run this file in standalone script mode"

The PEP 723 metadata block

# /// script
# requires-python = ">=3.11"
# dependencies = ["jinja2>=3.1", "requests>=2.31"]
# ///

This is the inline script metadata syntax standardized in PEP 723.

  • Lines in the block enclosed by # /// script and # /// have the # prefix stripped from each line and are then parsed as TOML
  • They are written as ordinary comments, so tools that do not support PEP 723 (old IDEs, linters, etc.) ignore them as plain comments. Compatibility is not broken
  • The fields have the same schema as the [project] table in pyproject.toml
    • requires-python = required Python version (PEP 440 format)
    • dependencies = dependency packages (PEP 508 format)

What happens at runtime

The flow when ./script.py is run is as follows.

  1. The kernel reads the shebang and starts /usr/bin/env -S uv run --script /path/to/script.py
  2. uv parses the # /// script block at the top of the file as TOML
  3. uv creates an isolated virtual environment just for this script in ~/.cache/uv/ (first run only; cached based on the script's hash)
  4. It installs the packages given in dependencies into that environment (first run only)
  5. It looks for a Python that satisfies requires-python. If there is none, uv downloads one automatically
  6. It runs script.py in that isolated environment

Comparison with the conventional approach

Item Conventional (uv project style) inline metadata + uv shebang
Dependency management Create a separate pyproject.toml Done within the one script file
Virtual environment Create with uv venv etc. uv creates and caches it automatically
Install uv add jinja2 / uv sync Automatic on first run
Python version management Specify with .python-version etc. uv fetches it automatically
Run uv run python script.py ./script.py directly
Distribution Hand over the whole project directory One file as an email attachment or Gist is enough

Benefits in numbers

  • First run: ~3 seconds (including dependency download and getting Python)
  • Second run onward: ~200ms (cache hit)
  • Distribution: complete in one script file. Works as one Gist, an email attachment, or pasted into Slack
  • Zero environment pollution: nothing is installed into the global Python; it runs in an isolated environment

Pitfalls

1. env -S doesn’t work on older Linux

The -S flag is a feature from coreutils 8.30 (2018) onward. It cannot be used on Ubuntu 18.04 and earlier, CentOS 7, and so on.

Workaround: do not write the shebang, and start it explicitly with uv run --script script.py.

2. uv must be installed

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

uv itself is distributed as a single binary, and installation is light (it is written in Rust).

3. Cache directory permissions

It fails without write permission to ~/.cache/uv/. This can be a problem in CI environments, inside containers, in sandbox environments, and so on.

It can be changed with an environment variable.

UV_CACHE_DIR=/tmp/uv-cache ./script.py

4. The IDE won’t recognize dependencies

VSCode's Pylance and the like cannot find the virtual environment that uv resolved, so warnings like Import "jinja2" could not be resolved can appear.

The fix is one of the following.

  • Ignore the warning (there is no real harm)
  • Add # pyright: reportMissingImports=false at the top of the file
  • Create a separate environment for the IDE with uv venv && source .venv/bin/activate && uv pip install jinja2

5. Few tools support PEP 723 yet

Other than uv, Hatch and pdm are adding support. pip alone cannot interpret this feature.

Use cases

It suits the following.

  • One-off scripts you want to distribute (internal tools, snippets shared on Gist)
  • Helper scripts that only run in CI
  • When you want to run "that bit of processing" that ChatGPT / Claude wrote for you as one file
  • Sample code pasted into blog posts
  • When you want to distribute a self-made CLI tool with dependencies in a light way

It does not suit the following.

  • Full applications split across multiple files (just use pyproject.toml as usual)
  • Uses where you want to cut startup latency as far as possible (even so, it is about 200ms after caching, which is acceptable for most uses)

References

Categories

Archive