I've recently changed my python project workflow based on a great write-up by Trey Hunner. The workflow makes setting up and using virtual environments for several projects fast and easy. It is also useful if you've only got a single, main project you're developing, as it allows you to get to the project, activate the virtualenv, and be ready to work, all with a single command.
Trey did a great job of explaining the details of what uv
is and why you might want to use it, so I won't go into too much detail here, other than to point out the uv
is remarkably fast. It's enough faster that, like rust, it changes how you use the tool by allowing you to run it frequently without noticeable time added to your work.
Trey started with a workflow that was similar to what I was using (virtualenvwrapper+pyenv) and ported it over to use direnv
and uv
. I have shamelessly copied his work, ported it to bash ('cause I'm old-school) and expanded it a bit. Let's walk through how I use it.
The Workflow
Before going into the details of how to set up this system, I'll walk you through what it looks like in operation. There are three commands, one used when creating a new project, one used in normal, day-to-day operations, and one primarily for debugging things.
Setting Up a Brand-New Project
To set up a new project in this workflow, it's easiest to create an empty directory, change into that directory, and then run the venv
command:
> mkdir my_new_project
> cd my_new_project
> venv
The venv
command will create a virtual environt for you and configure direnv
to automatically activate this virtual environment when you change to this directory. It also adds a mapping from this project name to this directory which will enable the workon
command, described below.
There are a few options you can use for the venv command. You can use something other than the directory name for your project name:
> venv this_name_is_better
You can specify which version of python you want:
> venv --python=3.11
If you don't currently have that version of python installed, uv
will auto-install it for you and then use it for the venv.
NOTE: Due to my lack of bash-magic, you must have the =
in this option. I hope to fix this at some point, but that may be by rewriting the tool in Python.
You can use any of the options available to uv venv
.
If you want to use options and use an non-default project name, the project name must come at the end. Again, this is due to lack of bash-ability on my part.
Setting up an Existing Project
If, instead of a brand new project, you want to use this on an existing project, say one you're going to clone from github, the process looks pretty much the same:
> git clone the_project
> cd the_project
> venv
This does everything described above with the additional step of looking for a requirements.txt file and, if it exists, running uv pip install -r requirements.txt
, thus setting you up to work immediately.
Working on a Project
Most of the time you're not setting up projects, you're just working on them. This is where the real benefit of this tooling comes into play.
When you open a new terminal, you are placed in your home directory, which is very likely not where your project lives (or at least it shouldn't be). This workflow has the workon
command which uses a simple database to map project names to their directories.
To start working on a project, you type:
> workon the_project
This will change you to that directory and, by the magic of direnv, activate the virtual environment automatically, so you're ready to work!
Note that the setup below also includes tab comletion for bash
which allows you to type workon <tab><tab>
to see a list of possible projects.
You can switch projects at any time using the workon
command. You do not need to deactivate the current venv or activate the new one, direnv
takes care of all that for you.
Removing Projects
If you want to remove a project, you can use the rmven <project-name>
command which removes the name from the database of projects and also remove the direnv and venv settings. You should do this from the project directory.
USE THIS COMMAND WITH CAUTION! It has not been tested extensively.
The Setup
To get this workflow to operate, follow these steps:
- Install
uv
anddirenv
. - Create a
~/.config/direnv/direnvrc
file with the following content shown below. This tellsdirenv
how you want it to deal with python programs and is referenced by the setup script we will create in the next step. - Add the large code block below to your
~/.bashrc
file.
If you like a fancy prompt on your command line, I recommend starship
which I also use. Trey does a good job of explaining it's configuration in the article referenced at the top.
Here is the contents of ~/.config/direnv/direnvrc
:
layout_python() {
VIRTUAL_ENV="$(pwd)/.venv"
PATH_add "$VIRTUAL_ENV/bin"
export VIRTUAL_ENV
}
Here's the .bashrc
code:
venv() {
local venv_name
local projects_file="$HOME/.projects"
local dir_name=$(basename "$PWD")
# If there are no arguments or the last argument starts with a dash, use dir_name
if [ $# -eq 0 ] || [[ "${!#}" == -* ]]; then
venv_name="$dir_name"
else
venv_name="${!#}"
set -- "${@:1:$#-1}"
fi
# Check if .envrc already exists
if [ -f .envrc ]; then
echo "Error: .envrc already exists" >&2
return 1
fi
if grep -Fq "^${venv_name} " ${projects_file}; then
echo "Error: a project named ${venv_name} already exists" >&2
return 1
fi
# Create venv
if ! uv venv --quiet --seed --prompt "$venv_name" "$@" .venv; then
echo "Error: Failed to create venv" >&2
return 1
fi
source .venv/bin/activate
# Create .envrc
echo "layout python" > .envrc
echo "unset PS1" >> .envrc
# Append project name and directory to projects file
echo "${venv_name} = ${PWD}" >> $projects_file
# Allow direnv to immediately activate the virtual environment
direnv allow
if [ -f requirements.txt ]; then
# Install requirements if requirements.txt exists
uv pip install -r requirements.txt
fi
}
workon() {
local project_name="$1"
local projects_file="$HOME/.projects"
local project_dir
# Check for projects config file
if [[ ! -f "$projects_file" ]]; then
echo "Error: $projects_file not found" >&2
return 1
fi
# Get the project directory for the given project name
project_dir=$(grep -E "^$project_name\s*=" "$projects_file" | sed 's/^[^=]*=\s*//')
# Ensure a project directory was found
if [[ -z "$project_dir" ]]; then
echo "Error: Project '$project_name' not found in $projects_file" >&2
return 1
fi
# Ensure the project directory exists
if [[ ! -d "$project_dir" ]]; then
echo "Error: Directory $project_dir does not exist" >&2
return 1
fi
# Change directories
cd "$project_dir"
}
rmvenv() {
# Remove a virtual environment
local venv_name
local projects_file="$HOME/.projects"
local dir_name=$(basename "$PWD")
# If there are no arguments or the last argument starts with a dash, use dir_name
if [ $# -eq 0 ] || [[ "${!#}" == -* ]]; then
venv_name="$dir_name"
else
venv_name="${!#}"
set -- "${@:1:$#-1}"
workon $venv_name
fi
# Check if .envrc already exists
if [ -f .envrc ]; then
echo "Removing .envrc"
rm .envrc
fi
if [ -d .venv ]; then
echo "Removing .venv"
rm -rf .venv
fi
if [ -d .direnv ]; then
echo "Removing .direnv"
rm -rf .direnv
fi
if grep -Fq ${venv_name} ${projects_file}; then
echo "Removing ${venv_name} from ${projects_file}"
sed -i "/^${venv_name}/d" ${projects_file}
fi
}
_workon_completions()
{
local projects_file="$HOME/.projects"
# This little bit of nasty pulls the first word from each line of .projects
x=$(awk '{ print $1 }' $projects_file)
local suggestions=($(compgen -W "$x" -- "${COMP_WORDS[1]}"))
if [ "${#suggestions[@]}" == "1" ]; then
# if there's only one match, we remove the command literal
# to proceed with the automatic completion of the number
local project=$(echo ${suggestions[0]/%\ */})
COMPREPLY=("$project")
else
# more than one suggestions resolved,
# respond with the suggestions intact
COMPREPLY=("${suggestions[@]}")
fi
return 0
}
# complete -o nospace -F _workon_completions workon
complete -F _workon_completions workon
# cuts down on the direnv chatter when running `workon`
export DIRENV_LOG_FORMAT=