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

2026-05-01 02:40 (2 hours ago)
Building a single-file portable Python script with embedded dependency management

When you want to write a “small script” in Python, the moment you try to use external libraries it becomes a hassle. You have to create a pyproject.toml, install dependencies with uv add (or uv sync), and ideally also set up a virtual environment… that whole process.

There’s a way to make it self-contained in a single Python file. Use 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, then you can execute it with:

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

No pip install, no python -m venv. Even if Python itself isn’t installed, uv will download it automatically.

Line-by-line explanation

The shebang line

#!/usr/bin/env -S uv run --script
  • #! = Unix shebang. The kernel looks at the beginning of the file and interprets it as “run this with …”
  • /usr/bin/env -S = The -S option for the env command. It means “it’s okay to split shebang arguments into multiple tokens using spaces.” Without it, uv run --script is interpreted as a single executable name, resulting in command not found.
    • Supported on macOS and Linux 4.0+
    • Doesn’t work on older 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 by PEP 723.

  • Lines inside the block delimited by # /// script and # /// are parsed as TOML, after stripping the # prefix from each line
  • Since it’s written as ordinary comments, tools that don’t support PEP 723 (older IDEs, linters, etc.) will ignore it as just comments. Compatibility isn’t broken.
  • The fields follow 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 running ./script.py:

  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 dedicated to this script under ~/.cache/uv/ (first run only; cached based on the script hash)
  4. uv installs the packages specified in dependencies into that environment (first run only)
  5. uv looks for a Python that satisfies requires-python. If none is found, uv downloads one automatically
  6. uv runs script.py inside that isolated environment

Comparison with the conventional approach

Item Conventional (uv project workflow) inline metadata + uv shebang
Dependency management Create pyproject.toml separately Self-contained in one script file
Virtual environment Create with uv venv etc. uv auto-creates and caches
Install uv add jinja2 / uv sync Automatic on first run
Python version management Specify via .python-version etc. uv auto-fetches
Run uv run python script.py Run directly with ./script.py
Distribution Share the whole project directory One file is enough (email attachment / Gist)

Benefits in numbers

  • First run: ~3 seconds (including dependency download and Python acquisition)
  • Subsequent runs: ~200 ms (cache hit)
  • Portability: A single self-contained script file. Works as one Gist, an email attachment, or a Slack paste
  • Zero environment pollution: Nothing is installed into global Python; it runs in an isolated environment

Pitfalls

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

The -S flag was introduced in coreutils 8.30 (2018). It can’t be used on Ubuntu 18.04 and earlier, CentOS 7, etc.

Workaround: omit the shebang and launch 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 lightweight (written in Rust).

3. Cache directory permissions

It will fail if there is no write permission to ~/.cache/uv/. This can be an issue in CI environments, containers, or sandboxed environments.

You can change it via an environment variable:

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

4. The IDE won’t recognize dependencies

VSCode’s Pylance and similar tools may not be able to find the virtual environment resolved by uv, so warnings like Import "jinja2" could not be resolved may appear.

Mitigations:

  • Ignore the warning (it won’t affect execution)
  • 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

Besides uv, Hatch and pdm are working on support. pip alone cannot interpret this feature.

Use cases

Good fits:

  • One-off scripts you want to distribute (internal tools, snippets shared via Gist)
  • Helper scripts used only in CI
  • Running “that processing” generated by ChatGPT / Claude as a single file
  • Sample code you paste into blog posts
  • Light distribution of a small custom CLI tool that has dependencies

Not a good fit:

  • Full applications split across multiple files (use pyproject.toml normally)
  • Cases where you want to minimize startup latency as much as possible (though post-cache it’s ~200 ms, which is fine for most use cases)

Summary

For a long time, Python has been criticized for “not being runnable with just a single shebang line” and “being annoying to set up when there are dependencies.”

With the combination of PEP 723 and uv, it becomes natural to do in Python what Bash and Node.js have long done by default: an executable script that is fully self-contained in a single file.

For small automation scripts, it’s reasonable for this to become the default.

References

Please rate this article
Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive