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-appThis 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 WillisonWhich 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-appIn 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 testHere’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.3Running 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-appUsage: demo-app [OPTIONS] COMMAND [ARGS]...
Demo
Options: --version Show the version and exit. --help Show this message and exit.
Commands: command Command description goes hereI can also run it via Python like this (producing the same output):
uv run python -m demo_appCrucially, 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 = trueOtherwise, 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 pytestI’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 syncoruv pip install, uv will search for a virtual environment in the following order:
- An activated virtual environment based on the
VIRTUAL_ENVenvironment variable.- An activated Conda environment based on the
CONDA_PREFIXenvironment variable.- A virtual environment at
.venvin 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.