Share Python Scripts Like a Pro: uv and PEP 723 for Easy Deployment

Updated April 8, 2025 · 15 min ·  

Python Uv Windows Linux MacOS

Cover Image

Summary

Sharing single-file Python scripts with external dependencies is now easy thanks to uv and PEP 723, which enable embedding dependency metadata directly within scripts. This approach eliminates the need for complex setup tools like requirements.txt or package managers, making script distribution and execution seamless and simplifying deployment while maintaining flexibility and efficiency.

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 or requests. 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. Using httpx 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, or tool.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:

OSVirtual 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

Share this Article