Building a single-file portable Python script with embedded dependency management
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-Soption of theenvcommand. It means "the shebang arguments may be split into several by whitespace". Without it,uv run --scriptis interpreted as one executable name and you getcommand 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
# /// scriptand# ///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 inpyproject.tomlrequires-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.
- The kernel reads the shebang and starts
/usr/bin/env -S uv run --script /path/to/script.py - uv parses the
# /// scriptblock at the top of the file as TOML - uv creates an isolated virtual environment just for this script in
~/.cache/uv/(first run only; cached based on the script's hash) - It installs the packages given in
dependenciesinto that environment (first run only) - It looks for a Python that satisfies
requires-python. If there is none, uv downloads one automatically - It runs
script.pyin 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=falseat 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.tomlas 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
Related posts
We look forward to discussing your development needs.