Skip to content
shellhacks.dev
Python

How to Write Single File Python Scripts With Dependencies

Write single file Python scripts that declare their own dependencies using PEP 723 inline metadata, run them with uv or pipx, lock versions and share them.

6 min read
Summarize in AI
Illustration for "How to Write Single File Python Scripts With Dependencies": a shell script in an editor, a request travelling to a server, an exit status badge.

A thirty-line script that needs one library is awkward to share: the receiver needs a requirements file, a virtual environment and instructions. PEP 723, "Inline script metadata", puts the dependency list at the top of the file, so one file is the whole program. It extends running scripts with uv. Examples use uv 0.5, pipx 1.7 and Python 3.13.

The metadata block

status.pyPython
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "requests<3",
#   "rich",
# ]
# ///
import requests
from rich import print

response = requests.get("https://example.org", timeout=10)
print(f"[bold]status[/bold] {response.status_code}")

The block starts and ends with a line of /// script and /// inside comments, and the lines between are TOML with every line prefixed by # . Dependencies use normal requirement strings, so you can pin a range such as requests<3. Python itself treats all of it as comments.

Run it

Shell
uv run status.py
pipx run status.py

Both tools create a throwaway environment with the dependencies, run the script and cache the environment for next time. The first run takes a moment to resolve; later runs start within a fraction of a second with uv. Neither modifies your global Python. A plain python3 status.py ignores the block and fails with an import error if the packages are not installed, which is a reasonable failure for a script that told you what it needs.

Edit the block with a command

Shell
uv init --script status.py --python 3.12
uv add --script status.py requests rich
uv lock --script status.py

uv add --script writes the dependency lines for you, which avoids TOML typos. uv lock --script creates status.py.lock next to the file with exact resolved versions; uv uses it on later runs, so a script run in two years gets the same packages. Without a lock, requests<3 resolves to whatever is newest at the moment.

A self-running file

Python
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["requests"]
# ///

With that shebang line, chmod +x status.py and ./status.py works on any machine that has uv. For machines without uv, pipx run reads the same block, though it cannot be used in a shebang as neatly. See executable Python scripts for the plain approach.

Where this does not apply

Anything beyond a single file, with several modules or tests, belongs in a project with pyproject.toml. Inline metadata cannot express optional dependencies or extra indexes in a portable way. Treat unpinned dependencies as a supply-chain choice: for scripts that run unattended with credentials, pin exact versions and check hashes. Packaging for installation as a command is covered in packaging a Python CLI.

A last practical point: put the metadata block directly after the shebang line and before any other code, with the exact spelling of the markers. Tools locate it with a strict regular expression, and a block with a stray space or a different marker is silently treated as ordinary comments, which shows up later as a confusing import error.

What the block is and who reads it

PEP 723 defines a comment block at the top of a single-file script that holds metadata in TOML. It starts with a line reading # /// script and ends with # ///, and every line between is a comment that begins with a hash and a space. Python itself ignores it, since it is only a comment. The tools read it: uv, pipx and several editors parse the block, create or reuse an environment with the listed dependencies and run the script inside it. The two keys that matter are dependencies, a list of requirement strings, and requires-python, a version specifier. The format is deliberately small, so that one standard replaces a pile of conventions such as requirements files kept beside scripts and comments saying which packages to install.

Because it is just a comment, running the script with plain python script.py works only if the dependencies are already installed. The metadata documents the needs, and does not install them unless a PEP 723 aware runner executes the file.

How environments are created and reused

When you run uv run script.py, uv resolves the dependencies from the block, builds an isolated environment in its cache and runs the script there, leaving no virtual environment folder next to the file. The environment is keyed on the resolved requirements, so a second run reuses it, and a changed dependency list builds a new one. This suits the scripts people copy around: a data fix, an API client, a one-off migration. The recipient needs only uv and the file, and does not have to create a project or read installation notes. Pin versions in the block, such as requests==2.32.3 or a compatible range, because an open specifier resolves to whatever is newest on the day of the run, and an old script can break after a major release.

For stronger reproducibility, uv supports an exclude-newer setting in the [tool.uv] table of the same block, which ignores any release published after a date. Setting it to the day you wrote the script gives the same resolution a year later, even with loose version ranges.

Limits and good habits

The block is for scripts, not projects. Once a file grows into several modules, a pyproject.toml and a lock file are the right structure, and the metadata moves there. The block cannot express everything a project can, such as optional extras and build settings, and tools differ in how much of the format they honor, so check the version of the runner when something behaves differently. Keep the dependencies minimal, since each one is a download and a supply chain risk for every person who runs the file, and prefer the standard library where it does the job. Review a script from someone else before running it, including its dependency list, because running it executes the code and installs the packages without a pause.

Keep the rest of the file tidy too. Put the block at the very top, before the docstring and the imports, since the specification looks for it at the start of the file and a misplaced block is silently ignored.

Sharing a script safely

A single file with inline metadata is easy to share, which makes it easy to run without thought. When you send one, say which runner and version it needs, and keep the file under version control with a short note on what it does and which systems it touches. When you receive one, read it. Check the dependency list for packages you do not recognize, and check for network calls and file writes. Running an unknown script installs its dependencies and executes both, so it deserves the same suspicion as any download. In a team, store approved scripts in a shared repository with review, so nobody runs a loose copy from a chat message.

Keep the requirement lines honest. Remove a dependency when you drop its import, and add a lower bound for any feature you rely on. A block that lists packages the script never uses slows every first run and widens the attack surface for nothing.

See PEP 723 and the inline script metadata specification.

Also inTooling
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.