The new Python package and project manager uv is in fact amazing.

I say that, because it’s really fast, but more importantly because this single tool does a whole lot, really fast: Installing Python binaries, installing and running packages in self-contained environments like pipx, managing virtual environments.

However, I’ve been avoiding it so far due to one flaw: uv defaults to installing its virtual environment and all dependencies into the .venv sub-directory of your project, almost exactly like the notorious node_modules.

(Although the obvious comparison to node_modules should be sufficient explanation, my specific reasons for preferring venvs outside of the project directory include, but are not limited to, real-time syncing across machines (note: real-time syncing is solving a different problem than git), working on the same source tree across different architectures and wanting my source trees to consist mostly out of… source, testing the project with differently configured but equally valid venvs (different Python versions for example).

Fortunately, uv recently added support for an environment variable named UV_PROJECT_ENVIRONMENT, which meant I could jury-rig a pretty good solution with direnv.

Let me show you:

Demo

Install uv and direnv (once-off)

Use any of the many uv installation methods to get this onto your system. On this MacBook I used homebrew.

Make sure you have direnv installed and add the necessary config to your shell startup. My .zshrc has the following:

1
eval "$(direnv hook zsh)"

Setup new project

Note that you really only need uv for this. uv will install a local Python for you when you need it.

1
2
$ uv init test313 --python 3.13
Initialized project `test313` at `/private/tmp/test313`

That single uv init, without answering any questions, will pre-populate a little directory for you with, amongst other things, the following pyproject.toml:

1
2
3
4
5
6
7
[project]
name = "test313"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []

Setup direnv to configure UV_PROJECT_ENVIRONMENT

This is the deviation from stock-standard uv.

I like to have all of my venvs all in $HOME/.virtualenvs/. In this case, the .envrc I install into the project will set UV_PROJECT_ENVIRONMENT to ~/.virtualenvs/PROJECT_BASENAME, which it gets from the current directory!

With this in place, I can just use uv as normal, and it will automatically use the correct out-of-source venv in ~/.virtualenvs/.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ cd test313 

$ echo 'export UV_PROJECT_ENVIRONMENT=$HOME/.virtualenvs/$(basename "$PWD")' > .envrc
direnv: error /private/tmp/test313/.envrc is blocked. Run `direnv allow` to approve its content                                                                                                                                                                                                                                                                                  

$ direnv allow
direnv: loading /private/tmp/test313/.envrc                                                                                                                                                                                                                                                                                                                                      
direnv: export +UV_PROJECT_ENVIRONMENT

$ echo $UV_PROJECT_ENVIRONMENT 
/Users/charlbotha/.virtualenvs/test313

Ready to roll

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ uv sync 
Using CPython 3.13.0 interpreter at: /opt/homebrew/opt/python@3.13/bin/python3.13
Creating virtual environment at: /Users/charlbotha/.virtualenvs/test313
Resolved 1 package in 13ms
Audited in 0.20ms

$ uv run python
Python 3.13.0 (main, Oct  7 2024, 05:02:14) [Clang 16.0.0 (clang-1600.0.26.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 

$ uv run which python
/Users/charlbotha/.virtualenvs/test313/bin/python

$ uv run python hello.py 
Hello from test313!

Note that in addition to any required packages, uv sync will even install the required Python for you if you have nothing available on your system yet.

Also, because the file does not yet exist, uv sync will create a uv.lock file with the solved dependencies.

Summary

Getting started with a new project using uv is the slickest I’ve ever seen.

Now, with the two additional commands (echo ... and direnv allow .), you can have your out-of-source venv cake and eat it too.

Bonus round: virtualenwvrapper-lite and “uv shell”

Add the following to your ~/.zshrc or ~/.bashrc for a virtualenvwrapper-like experience, as well as uvsh to do something similar to poetry shell.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
VENV_DIR=~/.virtualenvs

function mkvenv() {
    python3 -m venv "$VENV_DIR/$1"
}

function workon() {
    source "$VENV_DIR/$1/bin/activate"
}

function uvsh() {
    workon "$(basename "$PWD")"
}

function rmvenv() {
    rm -rf "$VENV_DIR/$1"
}