I have a nasty habbit of starting quick new Python projects somewhere in my chronologically organized Google Drive, uv init-ing them, and start adding packages.
By default, this means every project gets a .venv directory alongside its pyproject.toml. That works perfectly on a single machine. It also works as intended if you sync your projects with e.g. GitHub as those local .venv folders are just ignored. It’s less pleasant when the project directory lives in Google Drive, Dropbox, OneDrive, pCloud, or another synchronized folder and you switch between machines often. A Python virtual environment contains thousands of small files, compiled binaries, interpreter-specific paths, and platform-specific packages. Synchronizing it creates two problems. First, the sync client spends a disproportionate amount of time processing .venv (and a lot of sync engines hate a lot of small files). Second, a virtual environment created on Windows will not work correctly on macOS or Linux, which also causes uv run complaints.
The solution would be to stop putting project folders in Google Drive and just use Git. But I’m stubborn and don’t want to start a new repo for any small experiment or idea that more often than not turns out to go nowhere.
So my ideal setup is therefore:
- Google Drive:
- project/
- pyproject.toml
- uv.lock
- .python-version
- src/
- README.MD
- …
- project/
- Local machine (somewhere and somehow):
- uv-project-envs/
- project-a1b2c3d4e5f6/
- uv-project-envs/
At the same time, I still want to type: uv run python main.py, uv sync or uv add pandas (or more often: want AI agents to run this if they want to without issue). I do not want to remember --active or teach every coding agent to use a custom command or flag. It should also work on all platforms and not rely on symlink hocus pocus.
And thus, the solution is to put a small wrapper named uv earlier in the system PATH. This wrapper should find the current project root, create a short hash from the project’s absolute path, set UV_PROJECT_ENVIRONMENT to a machine-local directory and then call the real uv executable (which picks up UV_PROJECT_ENVIRONMENT). Renaming or moving the project creates a new local environment, but that’s ok and even desirable.
Setup
Make sure each project includes a Python version pin: e.g. uv python pin 3.12, i.e. that you have a .python-version. Normally this is already the case for uv projects. Also, it’s a good idea to still add .venv to .gitignore even though the wrapper will no longer create it.
macOS and Linux
- Locate the real uv executable using
command -v uv(I’ll use/Users/seppe/.local/bin/uv) mkdir -p "$HOME/binor another directory where we will make the wrappernano "$HOME/bin/uv"and paste the script belowchmod +x "$HOME/bin/uv"- Put the wrapper first in PATH
- For macOS using zsh, add this to
~/.zprofile:export PATH="$HOME/bin:$PATH" - For Linux, add it to
~/.profile:export PATH="$HOME/bin:$PATH" - Depending on how the Linux terminal starts Bash, it may also be useful to add the same line to
~/.bashrc source ~/.zprofileorsource ~/.profileor open a new Terminal
- For macOS using zsh, add this to
type -a uvto verify the wrapper appears firstuv --versionto make sureuvworks
Script $HOME/bin/uv:
#!/usr/bin/env bash
set -euo pipefail
# === IMPORTANT =========================================
# Replace this with the result of:
# command -v uv
REAL_UV="/absolute/path/to/the/real/uv"
if [[ ! -x "$REAL_UV" ]]; then
echo "uv wrapper: real uv executable not found at:" >&2
echo " $REAL_UV" >&2
echo "Update REAL_UV in $0." >&2
exit 1
fi
find_project_root() {
local dir="$PWD"
local candidate=""
while true; do
# A lockfile is the strongest indication of the actual uv root.
# This also behaves more sensibly for uv workspaces.
if [[ -f "$dir/uv.lock" ]]; then
printf '%s\n' "$dir"
return
fi
# Remember the nearest project marker in case there is no lockfile yet.
if [[ -z "$candidate" ]] && {
[[ -f "$dir/pyproject.toml" ]] ||
[[ -f "$dir/uv.toml" ]] ||
[[ -f "$dir/.python-version" ]];
}; then
candidate="$dir"
fi
if [[ "$dir" == "/" ]]; then
break
fi
dir="$(dirname "$dir")"
done
printf '%s\n' "$candidate"
}
project_root="$(find_project_root)"
# Outside a Python project, behave exactly like the real uv command.
if [[ -z "$project_root" ]]; then
exec "$REAL_UV" "$@"
fi
# macOS normally provides shasum; Linux normally provides sha256sum.
if command -v sha256sum >/dev/null 2>&1; then
project_hash="$(
printf '%s' "$project_root" |
sha256sum |
cut -c1-12
)"
elif command -v shasum >/dev/null 2>&1; then
project_hash="$(
printf '%s' "$project_root" |
shasum -a 256 |
cut -c1-12
)"
else
echo "uv wrapper: sha256sum or shasum is required." >&2
exit 1
fi
project_name="$(
basename "$project_root" |
tr -cd '[:alnum:]_.-'
)"
if [[ -z "$project_name" ]]; then
project_name="project"
fi
env_root="${
UV_LOCAL_ENV_ROOT:-${
XDG_DATA_HOME:-$HOME/.local/share
}/uv-project-envs
}"
mkdir -p "$env_root"
export UV_PROJECT_ENVIRONMENT="$env_root/${project_name}-${project_hash}"
exec "$REAL_UV" "$@"
Windows
On Windows, the most compatible solution is a small uv.cmd launcher combined with a PowerShell script. Using a .cmd launcher means the wrapper can be found by: PowerShell, Command Prompt, VS Code terminals, etc.
- Locate the real uv executable using
(Get-Command uv.exe).Source(I’ll useC:\Users\Seppe\.local\bin\uv.exe) New-Item -ItemType Directory -Force "$env:USERPROFILE\bin"or another directory where we will make the wrappernotepad "$env:USERPROFILE\bin\uv-wrapper.ps1"and paste the script belownotepad "$env:USERPROFILE\bin\uv.cmd"and paste the second script below- Put the wrapper first in PATH
- “Edit environment variables for your account”
- Add
%USERPROFILE%\binand make sure it is above the uv path - Close all open terminals
Get-Command uv -Allto verify the wrapper appears firstuv --versionto make sureuvworks
Script $env:USERPROFILE\bin\uv-wrapper.ps1:
$ErrorActionPreference = "Stop"
# === IMPORTANT =========================================
# Replace this with the result of:
# (Get-Command uv.exe).Source
$RealUv = "C:\absolute\path\to\the\real\uv.exe"
if (-not (Test-Path -LiteralPath $RealUv -PathType Leaf)) {
Write-Error "uv wrapper: real uv executable not found at: $RealUv"
exit 1
}
function Find-UvProjectRoot {
$dir = (Get-Location).ProviderPath
$candidate = $null
while ($true) {
$lockfile = Join-Path $dir "uv.lock"
# Prefer the nearest uv.lock. This is also more suitable
# for projects contained in a uv workspace.
if (Test-Path -LiteralPath $lockfile -PathType Leaf) {
return $dir
}
$hasProjectMarker = (
(Test-Path -LiteralPath (Join-Path $dir "pyproject.toml") -PathType Leaf) -or
(Test-Path -LiteralPath (Join-Path $dir "uv.toml") -PathType Leaf) -or
(Test-Path -LiteralPath (Join-Path $dir ".python-version") -PathType Leaf)
)
if ($null -eq $candidate -and $hasProjectMarker) {
$candidate = $dir
}
$parent = Split-Path -Path $dir -Parent
if (
[string]::IsNullOrEmpty($parent) -or
$parent -eq $dir
) {
break
}
$dir = $parent
}
return $candidate
}
$projectRoot = Find-UvProjectRoot
# Outside a project, call the real uv normally.
if ([string]::IsNullOrWhiteSpace($projectRoot)) {
& $RealUv @args
exit $LASTEXITCODE
}
# Windows paths are case-insensitive, so normalize the path before hashing.
$normalizedRoot = [System.IO.Path]::GetFullPath($projectRoot)
$normalizedRoot = $normalizedRoot.TrimEnd([char]'\')
$normalizedRoot = $normalizedRoot.ToLowerInvariant()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalizedRoot)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hashBytes = $sha.ComputeHash($bytes)
}
finally {
$sha.Dispose()
}
$projectHash = (
$hashBytes |
ForEach-Object { $_.ToString("x2") }
) -join ""
$projectHash = $projectHash.Substring(0, 12)
$projectName = Split-Path -Path $projectRoot -Leaf
$projectName = $projectName -replace '[^A-Za-z0-9_.-]', ''
if ([string]::IsNullOrWhiteSpace($projectName)) {
$projectName = "project"
}
if ([string]::IsNullOrWhiteSpace($env:UV_LOCAL_ENV_ROOT)) {
$envRoot = Join-Path $env:LOCALAPPDATA "uv-project-envs"
}
else {
$envRoot = $env:UV_LOCAL_ENV_ROOT
}
New-Item -ItemType Directory -Force -Path $envRoot |
Out-Null
$projectEnvironment = Join-Path `
$envRoot `
"$projectName-$projectHash"
$previousProjectEnvironment = $env:UV_PROJECT_ENVIRONMENT
$exitCode = 1
try {
$env:UV_PROJECT_ENVIRONMENT = $projectEnvironment
& $RealUv @args
$exitCode = $LASTEXITCODE
}
finally {
if ($null -eq $previousProjectEnvironment) {
Remove-Item `
Env:\UV_PROJECT_ENVIRONMENT `
-ErrorAction SilentlyContinue
}
else {
$env:UV_PROJECT_ENVIRONMENT = $previousProjectEnvironment
}
}
exit $exitCode
Script $env:USERPROFILE\bin\uv.cmd:
@echo off
REM You can also use pwsh.exe if PowerShell 7 is installed / preferred
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0uv-wrapper.ps1" %*
exit /b %ERRORLEVEL%
Notes
- The main reason for naming the wrapper uv, rather than something like uvw, is compatibility with automated tools
- Important caveat:
uv pip.UV_PROJECT_ENVIRONMENTapplies to uv’s project workflow, but it does not affect the low-leveluv pipinterface. When this usage is required, explicitly activate the environment first - Uninstall: simply remove from path, delete wrapper scripts and clean up
uv-project-envsfolder