We all love Python’s comprehensive standard library, but let’s face it – PyPI’s wealth of packages often becomes essential. Sharing single-file, self-contained Python scripts that rely on these external tools can be a headache. Historically, we’ve relied on requirements.txt
or full-fledged package managers such as Poetry or pipenv, which can be overkill for simple scripts and intimidating for newcomers. But what if there was a simpler way? That’s where uv and PEP 723 come in. This article delves into how uv harnesses PEP 723 to embed dependencies directly within scripts, making distribution and execution extremely easy.
uv and PEP 723
One of my favorite features of uv and its next-gen Python tooling is the ability to run single-file Python scripts that contain references to external Python packages without a lot of ceremony. This feat is accomplished by uv with the help of PEP 723 which is focused on “Inline script metadata.” This PEP defines a standardized method for embedding script metadata, including external package dependencies, directly into single-file Python scripts.
PEP 723 has gone through the Python Enhancement Proposal process and has been approved by the Python steering council and it is now part of the official Python specifications. Various tools in the Python ecosystem have implemented support including uv, PDM (Python Development Master), and Hatch. In this article, we focus on uv’s excellent support of PEP 723 to create and distribute single-file Python scripts.
While uv is also a package manager, it simplifies running self-contained Python scripts and stays out of the way, reducing the cognitive load for both script authors and users. Time to dive in and give it a try!
💡 Note: If you like this article, see also Packaging Python Command-Line Apps the Modern Way with uv
Setting the stage
We have created a Python script called wordlookup.py
to fetch definitions from a dictionary API. It’s looking pretty solid, but we want to distribute and give it to others to run with ease:
import argparse
import asyncio
import json
import os
import textwrap
import httpx
async def fetch_word_data(word: str) -> list:
"""Fetches word data from the dictionary API."""
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
except json.JSONDecodeError as exc:
print(f"Error decoding JSON for '{word}': {exc}")
return None
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
async def main(word: str):
"""Fetches and prints definitions for a given word with wrapping."""
data = await fetch_word_data(word)
if data:
print(f"Definitions for '{word}':")
try:
terminal_width = os.get_terminal_size().columns - 4 # 4 for padding
except OSError:
terminal_width = 80 # default if terminal size can't be determined
for entry in data:
for meaning in entry.get("meanings", []):
part_of_speech = meaning.get("partOfSpeech")
definitions = meaning.get("definitions", [])
if part_of_speech and definitions:
print(f"\n{part_of_speech}:")
for definition_data in definitions:
definition = definition_data.get("definition")
if definition:
wrapped_lines = textwrap.wrap(
definition, width=terminal_width,
subsequent_indent=""
)
for i, line in enumerate(wrapped_lines):
if i == 0:
print(f"- {line}")
else:
print(f" {line}")
else:
print(f"Could not retrieve definition for '{word}'.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Fetch definitions for a word.")
parser.add_argument("word", type=str, help="The word to look up.")
args = parser.parse_args()
asyncio.run(main(args.word))
This script imports several Python modules, setting the stage for a script that interacts with a dictionary API web service, processes JSON data, handles command-line arguments, utilizes asynchronous operations, formats text output, and interacts with the operating system to fetch the terminal width. With the exception of httpx
, an HTTP client library package, all of the other Python modules we import are part of the Python standard library. While I could technically accomplish the goal with Python’s built-in urllib.request module, I prefer httpx
. This, however, presents a dilemma since I will need a good way to distribute this script so my friends and coworkers can use it without a lot of fuss installing the needed httpx
dependency.
How do we solve this dilemma? uv to the rescue! We’ll walk through how this works next.
Note: This post received a lot of traction on Hacker News and sparked some great discussion (link). A point raised was the choice between Python’s built-in web client options versus libraries like
httpx
orrequests
. To clarify, my focus in this article is on demonstrating how to create and share a single-file Python script that relies on external Python packages, not specifically advocating for one web client versus another. Usinghttpx
is simply a demonstration of this concept.
Installing uv
As a first step, we need to install uv. Please refer to the official uv documentation for guidance on installing uv. A couple of common ways to install uv include:
# Assuming you have pipx installed, this is the recommended way since it installs
# uv into an isolated environment
pipx install uv
# uv can also be installed this way
pip install uv
uv is an amazingly versatile and, in my opinion, is very much the future of Python tooling. In this article, however, I’m just demonstrating one of uv’s awesome features for invoking single-file scripts with external dependencies.
Adding package dependencies in single-file scripts with uv
We’re now ready to add httpx
as a dependency in our wordlookup.py
script! Here’s how it’s done:
uv add --script wordlookup.py httpx
That’s it! After this, uv will add metadata in the comments at the top of our script. Here’s the first part of the script with a few lines after for context so you can see this in action:
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx",
# ]
# ///
import argparse
import asyncio
import json
import os
import textwrap
import httpx
If you have used pyproject.toml
with various Python tools such as Poetry, Flit, Hatch, Maturin, setuptools, etc., this syntax will likely look at least somewhat familiar. For example, Poetry might look like this:
# <-- other package metadata here -->
[tool.poetry.dependencies]
python = ">=3.13"
httpx = "^0.28.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
You will observe that uv adds the metadata for httpx
, but does not specify a version. uv will fetch the latest stable version of httpx from PyPI for use with the script. You can add dependency constraints by modifying the metadata directly after the fact or specifying a version dependency through the command line:
uv add --script wordlookup.py "httpx>=0.28.1"
Running your script with uv
We are ready to run our script. The uv tool makes it as simple to run as this (note that I am passing a --help
argument to the script as well):
$ uv run wordlookup.py --help
Installed 7 packages in 74ms
usage: wordlookup.py [-h] word
Fetch definitions for a word.
positional arguments:
word The word to look up.
options:
-h, --help show this help message and exit
When invoking the script with uv run
the first time, you will see some extra activity at the beginning as uv automatically creates an isolated virtual environment behind the scenes and fetches and installs the httpx
package and its associated dependencies. This is why we see Installed 7 packages in 74ms
in the terminal output.
If you try to run the script with python wordlookup.py
, the script will fail unless you happen to have httpx
installed globally or in your current virtual environment. How does uv use the script metadata? When invoking the script with uv run
, uv:
- Checks that the required Python version is available.
- Automatically creates an isolated virtual environment (without modifying your global Python environment).
- Installs the listed dependencies (
httpx
in this case) if they’re not already installed. - Executes the script.
For each subsequent launch of the script with uv run
, uv will leverage the virtual environment it created behind the scenes and invoke the script:
$ uv run wordlookup.py postulate
Definitions for 'postulate':
noun:
- Something assumed without proof as being self-evident or generally accepted, especially when used as a basis
for an argument. Sometimes distinguished from axioms as being relevant to a particular science or context,
rather than universally true, and following from other axioms rather than being an absolute assumption.
- A fundamental element; a basic principle.
- An axiom.
- A requirement; a prerequisite.
verb:
- To assume as a truthful or accurate premise or axiom, especially as a basis of an argument.
- To appoint or request one's appointment to an ecclesiastical office.
- To request, demand or claim for oneself.
adjective:
- Postulated.
If we add additional dependencies to our script or change the Python or httpx
version in the metadata, uv run
will create a new isolated virtual environment the next time it is invoked.
Making it even easier to run with a Python shebang
We can add a shebang (sometimes called a hashbang) at the top of the Python script to make it even easier to invoke the script with uv. I learned this excellent trick from Trey Hunner here.
Linux/macOS users
For Linux and macOS (and BSD users), add the following line at the top of the script:
#!/usr/bin/env -S uv run --script
📢 Update: For macOS, it turns out that the
-S
in the shebang is optional. The script will run fine either way. Thanks to Gregg Lind for bringing this to my attention!
Back to our regularly scheduled program…the fuller script context will look like the following at the top of the file:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx>=0.28.1",
# ]
# ///
import argparse
import asyncio
import json
import os
import textwrap
import httpx
Next, make the file executable:
chmod u+x wordlookup.py
Once that’s done, you can run the script directly, without needing to use the full uv run wordlookup.py
command:
./wordlookup --help
Windows users
For Windows users, you’re also in luck since the py launcher for Windows is also able to interpret shebangs. The py launcher is included by default when you install Python on Windows. Please note that you’ll need to omit the -S
from the shebang for the script to work correctly. The first line of your script should look like this:
#!/usr/bin/env uv run --script
You can when invoke the script on Windows as follows with the py
command:
py wordlookup.py
Note: This will not work if you invoke the script via
python wordlookup.py
since the shebang will not be interpreted.
When running from Command Prompt (cmd.exe), you can execute your Python script directly (e.g., ./wordlookup.py
) due to shebang interpretation. However, using py wordlookup.py
is recommended for consistent behavior across both Command Prompt and PowerShell.
Setting up your uv script to be invoked from anywhere on your computer
To make your uv (Python) script easily executable from anywhere on your system, you can move it to a common executable directory that’s included in your system’s PATH.
Linux/macOS users
For Linux and macOS users, copy the wordlookup.py
script to a directory in your systems $PATH
. On my system, the $HOME/bin
folder is in the path and I moved it there:
mv wordlookup.py ~/bin
I also elected to rename the file and remove the .py file extension to make it more ergonomic to invoke since the shebang contains all of the needed information to identify the file as a Python script:
mv wordlookup.py wordlookup
I am now able to invoke it from anywhere. (You will also observe that uv will create a new virtual environment and resolve the package dependencies the first time the Python script is invoked form the new location.)
$ wordlookup --help
Installed 7 packages in 21ms
usage: wordlookup.py [-h] word
Fetch definitions for a word.
positional arguments:
word The word to look up.
options:
-h, --help show this help message and exit
Windows users
For Windows users, you can either move the script to one of the directories already included in your system’s PATH
environment variable or add a new folder to the PATH
. I will assume you have created a folder called c:\scripts
and added it to your PATH
.
Next, create a file called wordlookup.cmd
and add the following contents:
@echo off
py c:\scripts\wordlookup.py %*
You will then be able to invoke the script from Windows Terminal or Command Prompt anywhere on the system like this:
wordlookup --help
Bonus: where does uv install its virtual environments?
Being a curious software engineer, I decided to dive deeper to see if I could discover where uv was installing its virtual environments on my Fedora Linux system. After all, I had wordlookup.py
sitting in its own dedicated directory. After running uv add --script
to add the httpx
package dependency metadata and invoking uv run
, a virtual environment directory such as .venv
was nowhere in sight in the local folder.
Update: The very day this article was originally published, uv v0.6.10 was released with a more convenient way of finding where the virtual environment resides. Thanks to user JimDabell on Hacker News for providing these insights.
To find where uv installed the virtual environment for a given single-file Python script in uv 0.6.10 or higher, invoke this command:
$ uv python find --script wordlookup.py
/home/dave/.cache/uv/environments-v2/wordlookup-f6e73295bfd5f60b/bin/python3
That was easy! We see that uv installs its virtual environments in one’s home directory at ~/.cache/uv/environments-v2/
.
Beyond just satisfying one’s curiosity, the location of the virtual environment can be useful since some Python tools need to know the location. For example, if I was using pyright (a static type checker for Python), I could supply the virtual environment location like this in Linux/macOS:
pyright --pythonpath $(uv python find --script wordlookup.py) wordlookup.py
The rest of this section is included for historic purposes since the techniques I describe could be useful to some in other troubleshooting endeavors.
I first started by finding all directories named httpx
on my system since a new folder by this name would likely get created on the first invocation of uv run
after the script had been created.
$ find -type d -name httpx
./.cache/uv/environments-v2/wordlookup-f6e73295bfd5f60b/lib/python3.13/site-packages/httpx
# <other folders found but omitted for brevity>
Lo and behold, I found a folder called httpx
in a parent folder called ./.cache/uv/environments-v2
. This looked promising.
I then discovered a command I could run (uv cache clean) to clear out all of the uv virtual environments. These would be harmless since the virtual environments could easily be recreated.
$ uv cache clean
Clearing cache at: .cache/uv
Removed 848 files (8.2MiB)
To watch everything in action on my Linux system (perhaps this was overkill 😃), I used inotifywait
to monitor all of the file create events that would occur when I invoked uv run wordlookup.py
since uv would need to recreate its virtual environment as I had cleared the cache.
inotifywait -m -r -e create ~/.cache/
# While this was running and waiting for event, I invoked `uv run wordlookup.py` from another terminal window
The inotifywait
command (part of the inotify-tools
package) waits for filesystem events and outputs them. Here are the arguments I used:
- -m (monitor): This option tells
inotifywait
to continuously monitor the specified directory for events. Without this,inotifywait
would only report the first event and then exit. - -r (recursive): This option tells
inotifywait
to recursively monitor the specified directory and all its subdirectories for events. Any new files or directories created within.cache/
or any of its subdirectories will trigger an event. - -e create (event: create): This option specifies that
inotifywait
should only report create events. A create event occurs when a new file or directory is created within the monitored directory. - .cache/: This is the directory that
inotifywait
was asked to monitor.
Sure enough, inotifywait
revealed the folders being dynamically created when uv run wordlookup.py
was launched.
When I copied the wordlookup.py
script to my $HOME/bin
folder and invoked it from there, I checked ./.cache/uv/environments-v2/
and yet another wordlookup-*
was created there housing the virtual environment.
In reviewing my Windows VM, I similarly found uv
virtual environments installed under %LOCALAPPDATA%\uv\cache
.
Upon further investigation, I found some uv cache directory documentation that described how uv determines the location of its cache directory. Here’s how it works:
uv determines the cache directory according to, in order:
- A temporary cache directory, if
--no-cache
was requested.- The specific cache directory specified via
--cache-dir
,UV_CACHE_DIR
, ortool.uv.cache-dir
.- A system-appropriate cache directory, e.g.,
$XDG_CACHE_HOME/uv
or$HOME/.cache/uv
on Unix and%LOCALAPPDATA%\uv\cache
on Windows
Typically, on Unix-like systems like my Fedora setup, uv stores its cache in $HOME/.cache/uv. However, you have the option to change this location by setting the $XDG_CACHE_HOME environment variable. For those unfamiliar with XDG, the XDG Base Directory Specification is a set of guidelines that applications follow to organize their files. It defines a few key environment variables that point to specific directories, ensuring that different types of application data are stored in their designated places. See here for more information.
To summarize, uv stores virtual environments for single-file Python scripts within its cache, typically at these OS-specific locations if you don’t do anything special to change the default:
OS | Virtual Environment Location |
---|---|
Linux | ~/.cache/uv/environments-v2/ |
macOS | ~/.cache/uv/environments-v2/ |
Windows | %LOCALAPPDATA%\uv\cache\environments-v2 |
Update: An astute user on Hacker News (sorenjan) pointed out that you can also run uv cache dir
to find the location of the cache directory root (e.g. ~/.cache
).
How does uv derive its virtual environment folder name?
Take a look at the following uv virtual environment folder on my Linux system. How is the folder name of wordlookup-f6e73295bfd5f60b
generated?
./.cache/uv/environments-v2/wordlookup-f6e73295bfd5f60b
My preliminary investigation of uv’s Rust code and other resources suggests that the virtual environment folder names are generated from a hash of the Python version and the external package dependency versions (such as httpx
in my context). This design ensures that any modification to these elements, including the script’s name (which is embedded in the folder name itself), results in the creation of a unique virtual environment in the cache. I validated this empirically by observing that uv created a new virtual environment if I specified a different version of httpx
in the metadata or if I changed the name of the script file.
Conclusion
In conclusion, uv with its implementation of PEP 723 is an awesome tool that simplifies the way we handle single-file Python scripts with external dependencies. By embedding metadata directly within the script, uv eliminates the need for separate requirements.txt
files and complex package managers. uv streamlines the process of installing dependencies and managing virtual environments, making it significantly easier to run these scripts. The added convenience of shebangs and system-wide executables further enhances usability. Ultimately, this combination makes Python scripting more accessible, particularly for single-file scripts, and promises a more streamlined workflow for both developers and users.
Updated April 8, 2025. Originally published March 25, 2025