Use uv and forget about virtualenvwrapper
I just recently updated from ubuntu 22.04 to 24.04 and as usually happens when upgrading the OS, some things break. In my case, I use python environments to manage the packages for working with single cell and spatial data, and it just stopped working. I always used virtualenvwrapper to manage my environments so I used the good old pip command to install it.
1python3 -m pip install --user virtualenvwrapper
However, I got a very long error. It turns out that’s the expected new behavior in Ubuntu 24.04 (Python 3.12). Ubuntu now ships with PEP 668 protection, which prevents pip from modifying system-managed Python installations. So I had to install it using apt.
1sudo apt update
2sudo apt install virtualenvwrapper
and added the following lines to my .bashrc file as usual.
1export WORKON_HOME=$HOME/.virtualenvs
2export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3
3source /usr/share/virtualenvwrapper/virtualenvwrapper.sh
Now I got a working installation of virtualenvwrapper but my old environments were broken. The reason is the old virtual environments (created under Ubuntu 22.04) were based on Python 3.10. When Ubuntu upgraded to Python 3.12, those old environments’ Python binaries and libraries vanished. Normally, when a new virtual environment is created with virtualenv or venv, it just links to the system Python (e.g. /usr/bin/python3.12) and if that system Python disappears (as it did when Ubuntu upgraded from 3.10 → 3.12), the environment breaks.
There are some solutions to this problem and create self-contained environments. However, I found a much easier solution: using uv, a modern alternative to virtualenvwrapper. uv (from Astral, the team behind Ruff) is a modern, ultra-fast package and environment manager that can completely replace both virtualenvwrapper and pip and it does exactly what we need, every environment automatically bundles its own Python interpreter, independent from the system Python (no more Ubuntu upgrade breakages!!).
Installing uv is as simple as running this command
1curl -LsSf https://astral.sh/uv/install.sh | sh
1downloading uv 0.9.2 x86_64-unknown-linux-gnu
2no checksums to verify
3installing to /home/alfonso/.local/bin
4 uv
5 uvx
6everything's installed!
Now we enable uv and uvx shell autocompletion as follows
1echo 'eval "$(uv generate-shell-completion bash)"' >> ~/.bashrc
2echo 'eval "$(uvx --generate-shell-completion bash)"' >> ~/.bashrc
which basically adds those lines to the end of the .bashrc file. Now just restart the shell or source the .bashrc file.
Now we can rescue the old environments as follows
1mkdir -p ~/env_reqs_backup
2
3for env in ~/.virtualenvs/*; do
4 # only continue if the folder has its own python binary
5 if [ -d "$env/bin" ] && [ -x "$env/bin/python" ]; then
6 name=$(basename "$env")
7 echo "Trying $name ..."
8 # try pip freeze
9 "$env/bin/python" -m pip freeze > ~/env_reqs_backup/"$name".txt 2>/dev/null
10 # if freeze failed, try to reconstruct from dist-info
11 if [ ! -s ~/env_reqs_backup/"$name".txt ]; then
12 site=$(find "$env/lib" -type d -name "site-packages" | head -n1)
13 if [ -n "$site" ]; then
14 ls "$site" | grep dist-info | sed 's/\.dist-info//' > ~/env_reqs_backup/"$name".txt
15 echo "Used fallback dist-info for $name"
16 else
17 echo "o site-packages found for $name"
18 fi
19 else
20 echo "Saved ~/env_reqs_backup/$name.txt"
21 fi
22 fi
23done
This will:
- Create a folder to store requirement files
- Skip the hook scripts automatically, meaning working only on real environments
- Try pip freeze first.
- If that fails, fallback to manual listing the package metadata.
- Save everything cleanly in ~/env_reqs_backup/.
However, the saved files do not have the correct format to rebuild the environments, they look as follows
1pynndescent-0.5.13
2pyparsing-3.2.1
3python_dateutil-2.9.0.post0
4pytz-2025.1
5pyzmq-26.2.1
6scanpy-1.11.0
but should be
1pynndescent==0.5.13
2pyparsing==3.2.1
3python-dateutil==2.9.0.post0
4pytz==2025.1
5pyzmq==26.2.1
6scanpy==1.11.0
we can fix them as follows
1sed -E -i 's/_/-/g; s/-([0-9])+/==\1/' ~/env_reqs_backup/*.txt
My idea was to recreate centralized environments with from these requirement files. However, taking into account the huge speed of installation I decided to move to a project centric approach.
uv supports managing Python projects, which define their dependencies in a pyproject.toml file. Projects can be created from scratch
1uv init new_project
2cd new_project
or can be initialized within the working directory of an ongoing project
1cd current_project_folder
2uv init
uv will create the following files:
1├── .gitignore
2├── .python-version
3├── README.md
4├── main.py
5└── pyproject.toml
The main.py file contains a simple "Hello world" program.
The pyproject.toml contains metadata about the project.
The .python-version file contains the project's default Python version. This file tells uv which Python version to use when creating the project's virtual environment.
Now we can install the main packages of the environment with uv add
1uv add numpy pandas anndata scipy scanpy
1Resolved 42 packages in 116ms
2Prepared 2 packages in 132ms
3Installed 40 packages in 641ms
This will create a virtual environment and uv.lock file in the root of your project. The complete list of added files would look like:
1.
2├── .venv
3│ ├── bin
4│ ├── lib
5│ └── pyvenv.cfg
6├── .python-version
7├── README.md
8├── main.py
9├── pyproject.toml
10└── uv.lock
The .venv folder contains the project's virtual environment, a Python environment isolated from the rest of the system. This is where uv will install the project's dependencies.
uv.lock is a cross-platform lockfile that contains exact information about the project's dependencies. It contains the exact resolved versions that are installed in the project environment. It allows consistent and reproducible installations across machines.
Environments were not the only thing that broke, we will install and run jupyter lab with a single command
1uvx jupyter lab
uvx (shortcut for uv tool run) jupyter lab means:
- Create (or reuse from cache) an isolated venv
- Resolve and install jupyter with the lab subcommand (i.e. JupyterLab), it installed 99 packages in 93ms
- Run it, without touching your project’s pyproject.toml or current venv
- Next time will be even faster because uv reuses the cached env.
run uvx jupyter lab . to open jupyter lab on your project folder
Further reading
- uv offical documentation
- Stephen Turner's blog: uv, part 1: running scripts and tools
- Stephen Turner's blog: uv, part 2: building and publishing packages
- Stephen Turner's blog: uv, part 3: Python in R with reticulate
- Stephen Turner's blog: uv, part 4: uv with Jupyter