In my previous post, I shared how to use uv and PEP 723 to create single-file Python scripts with external dependencies. This article made it to the front page of Hacker News (link), demonstrating there’s a lot of excitement around uv and its capabilities. Today we’re going to focus on using uv to build a professional grade CLI application that can be distributed as a Python wheel (.whl) and uploaded to PyPI or simply given to others to install and use.
For simplicity, we’ll continue using our dictionary API example to illustrate uv’s dependency management and packaging capabilities. Let’s get started!
Installing uv
As a first step, we need to install uv, if it’s not’s already installed. 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
Initializing a new uv project
Next, we’ll initialize a new project with uv. This first command below creates a directory named wordlookup
and establishes the project structure as a package, which is ideal for a CLI application. This will enable us to build a Python wheel (.whl) file that can be published to PyPI or simply distributed to others to install and use. Be sure to include the --package
flag.
uv init wordlookup --package
cd wordlookup
Alternatively, you can initialize a project in the working directory:
mkdir wordlookup
cd wordlookup
uv init --package
After invoking the uv init
command, uv creates an initial folder structure as follows:
$ tree -a wordlookup
wordlookup
├── pyproject.toml
├── .python-version
├── README.md
└── src
└── wordlookup
└── __init__.py
If you’re acquainted with Rust’s development tools, uv init
will feel like a natural extension, mirroring Cargo’s cargo
init for project setup. This method of initialization, shared by numerous language ecosystems, provides us with a solid foundation to begin building.
Running the project the first time
In reviewing the files created, we find the obligatory hello world code in __init__.py
ready for action:
def main() -> None:
print("Hello from wordlookup!")
Let’s run our newly provisioned project as a first test:
$ uv run wordlookup
Using CPython 3.13.2 interpreter at: /usr/bin/python3.13
Creating virtual environment at: .venv
Installed 1 package in 7ms
Hello from wordlookup!
Very nice! On the first run, uv creates a virtual environment for us at .venv
to promote good Python app building hygiene to isolate apps that contain external dependencies before running the code and greeting us with a message.
When you initially run a uv command—such as uv run
, uv sync
, or uv lock
—a virtual environment and a uv.lock
file are created at the project’s root. This uv.lock
file captures the precise versions of your project’s dependencies, guaranteeing reproducible builds.
Adding the core CLI code
Let’s create a file called cli.py
in the src
directory alongside __init__.py
and add the following code:
import argparse
import json
import os
import sys
import textwrap
import httpx
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:
with httpx.Client() as client:
response = 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
def main():
"""Fetches and prints definitions for a given word with wrapping"""
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()
word = args.word
data = 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__":
sys.exit(main())
Next, update the pyproject.toml
file under the project.scripts
table (in TOML parlance) so uv understands that cli.py
is the entry point for the application:
[project.scripts]
wordlookup = "wordlookup.cli:main"
While we could have added all our code to __init__.py
, we are instead using cli.py
as our entry point. (This is somewhat a matter of software design preference.) Also, we’re keeping it simple, but our project could include multiple python source (.py
) files to support our CLI application.
Although __init__.py
is no longer used as the entry point in our project, it must remain to designate the directory as a Python package. Keep the __init__.py
file but remove all of its contents.
Adding external package dependencies
Since we have included code that relies on httpx
which is not part of the built-in Python library, we need to add httpx
as an external package dependency. This is accomplished as follows from the project root:
$ uv add httpx
Resolved 8 packages in 461ms
Built wordlookup2 @ file:///home/dave/dev/python/wordlookup
Prepared 1 package in 339ms
Uninstalled 1 package in 0.48ms
Installed 8 packages in 80ms
+ anyio==4.9.0
+ certifi==2025.1.31
+ h11==0.14.0
+ httpcore==1.0.7
+ httpx==0.28.1
+ idna==3.10
+ sniffio==1.3.1
~ wordlookup==0.1.0 (from file:///home/dave/dev/python/wordlookup)
As shown above, uv adds httpx
and its required dependencies, ensuring it’s ready for use.
Inspecting the pyproject.toml
file, we see that httpx
has been added under dependencies
:
[project]
name = "wordlookup"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Dave Johnson", email = "[email protected]" }
]
requires-python = ">=3.13"
dependencies = [
"httpx>=0.28.1",
]
[project.scripts]
wordlookup = "wordlookup:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Running the project
We’re now ready to run our project and make sure it works as expected using uv run
.
Note that we pass the name of the project specified in our pyproject.toml
file to the uv run
command which is wordlookup
:
$ uv run wordlookup
usage: wordlookup [-h] word
wordlookup: error: the following arguments are required: word
Our CLI app ran successfully! The error simply means we need to provide a word as a parameter. Let’s pass munificence
(I’m not sure why this esoteric word surfaced on my brain 😃) as an argument to the wordlookup
CLI app so we can confirm the project is in good working order:
$ uv run wordlookup munificence
Definitions for 'munificence':
noun:
- The quality of being munificent; generosity.
noun:
- Means of defence; fortification.
Excellent! Our app is yielding the expected results and we’re ready to prepare it for packaging and distribution.
Preparing the project for packaging
Before packaging our project for distribution, we’ll want to update a couple of files.
pyproject.toml
: modify the top section of yourpyproject.toml
to set the desired package version, description, and other relevant metadata. You might also want to add a[project.urls]
TOML table with a link to your code repository, if you have one. For reference, here’s mypyproject.toml
after updates:
[project]
name = "wordlookup"
version = "0.1.0"
description = "Fetches and prints definitions for a given word"
readme = "README.md"
authors = [
{ name = "Dave Johnson", email = "[email protected]" }
]
requires-python = ">=3.13"
dependencies = [
"httpx>=0.28.1",
]
[project.urls]
Repository = "https://github.com/thisdavej/wordlookup"
[project.scripts]
wordlookup = "wordlookup.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
README.md
: update this file with the information you intend to include on both your GitHub repository’s landing page and your PyPI package page, should you distribute it to these places. Of course, any information you include here will be useful for those reviewing the app’s source code, regardless of whether it is published on a broader scale.
You’ll find an example GitHub repo here.
Building the project
We’re now ready to build our Python app so it can be distributed to others. The uv build
is used to build both source distributions and binary distributions for our project. By default, uv build
will build the project in the current directory, and place the built artifacts in a dist/
subdirectory:
$ uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/wordlookup-0.1.0.tar.gz
Successfully built dist/wordlookup-0.1.0-py3-none-any.whl
The files created in our context are:
dist/wordlookup-0.1.0.tar.gz
: this is a source distribution (sdist) which provides the raw source code of a Python package, along with the necessary metadata and instructions to build the package on the user’s system.dist/wordlookup-0.1.0-py3-none-any.whl
: this is a Python wheel (.whl
file) which is a pre-built, ready-to-install binary distribution of a Python package for a specific Python version, architecture and operating system. In this context, however, we have built a “pure Python” wheel that is platform independent since our code contains no compiled extension modules written in C, C++, or Rust.
Installing the Python wheel on the local dev system
We can use the uvx
command, included when uv is installed, for testing. The uvx
command, an alias for uv tool run
, is used to run the command-line tool provided by a Python wheel without explicitly installing the tool into a persistent environment or adding it to our system’s PATH.
$ uvx dist/wordlookup-0.1.0-py3-none-any.whl munificence
Installed 8 packages in 75ms
Definitions for 'munificence':
noun:
- The quality of being munificent; generosity.
noun:
- Means of defence; fortification.
Looking good!
Note: If you’re curious, you can find the place where uv store’s it’s ephemeral virtual environments created by
uvx
and when creating single-file scripts by runninguv cache dir
to find the cache directory. This is explained in detail in my other article on sharing single-file Python scripts using uv and PEP 723 under the where does uv install its virtual environments? section.
We can also install our CLI app in a more permanent way on our system using uv tool install
:
uv tool install dist/wordlookup-0.1.0-py3-none-any.whl
On Linux/macOS, we can ascertain the location of the installation using the which
command:
$ which wordlookup
~/.local/bin/wordlookup
On Windows, we can determine the location of the installation using where.exe
:
C:\> where.exe wordlookup
c:\Users\dave\.local\bin\wordlookup.exe
Therefore, we can navigate to %USERPROFILE%\.local\bin
in Windows to find the location of the CLI app and other executables installed with the uv tool install
command.
Distributing your Python CLI app so others can use it
Now that we have created and tested our newly minted Python (.whl) file, we are ready to distribute it to friends and coworkers. This distribution can happen several different ways including:
Local installation
Email the wheel (.whl
) file or host it on a local file share. End users can then install your CLI app from the local wheel file in multiple ways.
If the end user has uv installed on their system, they can install your CLI app easily in an isolated virtual environment which is a recommended best practice to avoid dependency conflicts if different tools require different versions of the same libraries.
One option is to use uvx which will install the CLI app in a non-persistent virtual environment which uvx will re-create on the fly as needed:
uvx wordlookup-0.1.0-py3-none-any.whl
The CLI app can also be installed in a persistent, isolated virtual environment managed by uv using uv tool install
:
uv tool install wordlookup-0.1.0-py3-none-any.whl
While we used uv to build the Python package, end users do not need uv installed in order to install and use the package. The CLI app can also be installed on a system using pip:
pip install ./wordlookup-0.1.0-py3-none-any.whl
Keep in mind that this installs the package into the user’s global site-packages rather than an isolated environment specific to the tool. As a result, it may cause dependency conflicts if multiple tools need different versions of the same libraries.
Also, pipx can be used to install the CLI app in an isolated environment. In my testing, pipx was noticeably slower than uv—likely because pipx is written in Python, while uv is implemented in Rust. That said, the cheerful pipx emojis at the end of installation do add a fun, shiny touch. 😉
$ pipx install wordlookup-0.1.0-py3-none-any.whl
installed package wordlookup 0.1.0, installed using Python 3.13.2
These apps are now globally available
- wordlookup
done! ✨ 🌟 ✨
Web-based installation
Alternatively, you can host the wheel generated by uv on a web server, allowing users to install and run it directly from a URL using uvx.
uv tool install https://example.com/path/to/wordlookup-0.1.0-py3-none-any.whl
Installing from PyPI
When considering distribution methods for your package, publishing to PyPI is another option if your audience is broader. There are many ways of publishing to PyPI along with associated tutorials. I recommend using uv publish
as described here. Twine is another established method that predates uv.
Note: I have published the
wordlookup
package to PyPI here so running the next commands will install the package I created. These next commands are here for illustrative purposes, and you would choose a different name for your specific Python CLI application.
End users can then install your CLI app directly from PyPI:
# using uv
uv tool install wordlookup
# using pipx
pipx install wordlookup
Installing from GitHub
For others to install your CLI app from GitHub, here are the steps:
- Clone the repository:
git clone https://github.com/thisdavej/wordlookup-tutorial.git
cd wordlookup-tutorial
- Build using uv:
uv build
- Test the Python wheel created during the build step:
uv tool run wordlookup
# You can also use `uvx` which is an alias for `uv tool run`
uvx wordlookup
- Install the Python wheel
uv tool install wordlookup
Finally, as another option, the end user can download and build from the GitHub rep to test the CLI app:
uvx git+https://github.com/thisdavej/wordlookup-tutorial.git
The end user can also install your CLI app in a persistent, isolated virtual environment managed by uv from GitHub using uv tool install
:
uv tool install git+https://github.com/thisdavej/wordlookup-tutorial.git
Uninstalling the CLI app
If the end user no longer wishes to keep the CLI app on their system (I can’t imagine why since it is so awesome! 😀), it can removed.
If the app was installed with uv tool install
, it can be uninstalled like this:
uv tool uninstall wordlookup
…and if the app was installed with pipx
, it be be uninstalled this way:
pipx uninstall wordlookup
Conclusion
In conclusion, this walkthrough has demonstrated the power and simplicity of using uv to build and package a professional-grade Python CLI application. By leveraging uv’s efficient dependency management and build capabilities, we successfully created a functional wordlookup CLI app and built a distributable Python wheel. We also explored various methods for sharing this application with others, whether through local file sharing or publishing to PyPI. This process highlights uv as a compelling tool for modern Python development and distribution workflows.
Subscribe to my RSS feed 📡 to stay up to date with my latest tutorials and tech articles.
Updated April 23, 2025. Originally published April 8, 2025