Using uv to develop Python command-line applications
I finally figured out a process that works for me for hacking on Python CLI utilities using uv to manage my development environment, thanks to a little bit of help from Charlie Marsh.
Starting a new app with cookiecutter
I already have a cookiecutter template I like using for CLI applications: simonw/click-app.
Thanks to uvx
I don’t even need to install cookiecutter
to use it:
uvx cookiecutter gh:simonw/click-app
This outputs a set of questions:
[1/6] app_name (): demo-app [2/6] description (): Demo [3/6] hyphenated (demo-app): [4/6] underscored (demo_app): [5/6] github_username (): simonw [6/6] author_name (): Simon Willison
Which creates a demo-app
directory containing the skeleton of a Python project.
Setting up the uv virtual environment
uv
has a number of different commands that can create and work with a .venv
virtual environment directory.
cd demo-app
In this case, my pyproject.toml
file (created by that cookiecutter template) defines a separate block of test dependencies. Here’s that TOML file in full:
[project]name = "demo-app"version = "0.1"description = "Demo"readme = "README.md"authors = [{name = "Simon Willison"}]license = {text = "Apache-2.0"}requires-python = ">=3.8"classifiers = [ "License :: OSI Approved :: Apache Software License"]dependencies = [ "click"]
[build-system]requires = ["setuptools"]build-backend = "setuptools.build_meta"
[project.urls]Homepage = "https://github.com/simonw/demo-app"Changelog = "https://github.com/simonw/demo-app/releases"Issues = "https://github.com/simonw/demo-app/issues"CI = "https://github.com/simonw/demo-app/actions"
[project.scripts]demo-app = "demo_app.cli:cli"
[project.optional-dependencies]test = ["pytest"]
The [project.optional-dependencies]
section lists that test
block. I can create a new virtual environment in .venv/
and install both my project dependencies and those test dependencies like this:
uv sync --extra test
Here’s the output:
Using CPython 3.11.1Creating virtual environment at: .venvResolved 9 packages in 207ms Built demo-app @ file:///private/tmp/for-uv/demo-appPrepared 1 package in 614msInstalled 6 packages in 8ms + click==8.1.7 + demo-app==0.1 (from file:///private/tmp/for-uv/demo-app) + iniconfig==2.0.0 + packaging==24.1 + pluggy==1.5.0 + pytest==8.3.3
Running the tests
Now I can run pytest
using the uv run
command:
uv run pytest
==================== test session starts ====================platform darwin -- Python 3.11.1, pytest-8.3.3, pluggy-1.5.0rootdir: /private/tmp/for-uv/demo-appconfigfile: pyproject.tomlcollected 1 item
tests/test_demo_app.py . [100%]
===================== 1 passed in 0.03s =====================
This runs the pytest
binary in the current .venv/
environment. Note that I no longer have to “activate my virtual environment” - using uv run
habitually solves that for me.
Running the CLI tool itself
This line in pyproject.toml
defines a script entry point for my CLI tool:
[project.scripts]demo-app = "demo_app.cli:cli"
If the tool is correctly installed, I should be able to run it like this:
uv run demo-app
Usage: demo-app [OPTIONS] COMMAND [ARGS]...
Demo
Options: --version Show the version and exit. --help Show this message and exit.
Commands: command Command description goes here
I can also run it via Python like this (producing the same output):
uv run python -m demo_app
Crucially, the only reason this works is that I included this section in pyproject.toml
:
[build-system]requires = ["setuptools"]build-backend = "setuptools.build_meta"
This may seem unrelated, but it’s necessary for the demo-app
alias to be correctly installed. As Charlie Marsh explained it:
We support two kinds of projects: packages and non-packages. You want the former in this case, because you actually want to install the package in the environment. (We used to require this, but a lot of people want to be able to create lightweight projects that are just collections of scripts and don’t need to be buildable / installable into an environment. We call those non-package projects.)
We consider a project to be a “package” if
[build-system]
is defined or you settool.uv.package = true
Otherwise, we don’t install the project itself into the environment.
Using dev-dependencies instead
The only reason I needed to use uv sync
here was to specify that --extra test
to get my test dependencies installed as well.
As an aside, the following would have worked instead:
uv run --extra test pytest
I’d only need to pass that --extra test
option the first time I ran uv run
- on subsequent runs the test dependencies would already be installed.
Another option here would be to use the newer concept of dev-dependencies. uv
supports these right now, and they’ve just been standardized by PEP 735. To use those, add this to the pyproject.toml
file:
[tool.uv]dev-dependencies = ["pytest"]
Then uv run pytest
would work without needing to use --extra
to ensure the test dependencies are installed, and without needing to use uv sync
at all.
There’s no need for uv pip
I got into a tangle at first trying to figure this out, because I thought I needed to use uv pip
to manage my environment… and it turns out uv pip
followed these rules:
When running a command that mutates an environment such as
uv pip sync
oruv pip install
, uv will search for a virtual environment in the following order:
- An activated virtual environment based on the
VIRTUAL_ENV
environment variable.- An activated Conda environment based on the
CONDA_PREFIX
environment variable.- A virtual environment at
.venv
in the current directory, or in the nearest parent directory.
Update: This changed in uv 0.5.0 so Conda should no longer result in this confusion.
I had Conda installed, which means I had a CONDA_PREFIX
environment variable set, which meant uv pip
was ignoring my .venv
directory entirely and using the Conda environment instead!
This caused all manner of confusion. I put together this document and asked Charlie for help, and he graciously unblocked me.