Every Python developer runs two kinds of software from the command line. There is the project — the CLI you are building, its dependencies and its tests — and there are tools: Ruff, mypy, pre-commit, httpie, the AWS CLI, cookiecutter, your own team's deploy script. Mixing them causes a slow trickle of strange problems. A tool installed into the project's virtual environment pins a version of rich that conflicts with yours. A globally installed tool upgrades and the project's formatting changes for one developer only. A command that "works on my machine" turns out to come from a different environment than everyone assumed. The fix is to decide, for each tool, which of three homes it belongs in — and to be able to check which home a command actually came from. This guide covers both. It belongs to the virtual environments topic.
Prerequisites
- uv (and optionally pipx) installed.
- A CLI project with a virtual environment, for example created by
uv sync.
Three homes for a tool
1. The project's dependency groups — for tools that must import your code or see your dependencies. pytest imports your package; mypy and Pyright need your dependencies to resolve types; a docs generator that renders your CLI's help imports your commands. These belong in [dependency-groups] (uv add --dev pytest mypy), locked with everything else, and run with uv run.
2. A pinned, ephemeral run — for tools that only read files and should match a version the project chooses. Ruff, a Markdown linter, pip-audit, a YAML formatter: uvx ruff@0.16.10 check . runs that exact version from a cached, isolated environment without installing anything into the project. The version lives in the command (or in the pre-commit configuration), so every developer and CI use the same one.
3. An isolated, installed tool — for tools you use across many projects and want on your PATH permanently: httpie, the cloud CLIs written in Python, cookiecutter, your team's internal tools. uv tool install httpie or pipx install httpie gives each its own virtual environment and links its executables into ~/.local/bin. These are personal; the project should never depend on them being present.
The deciding question is simple: does the tool need to import the project? If yes, home 1. If no but the project cares about the exact version, home 2. Otherwise, home 3.
The recipe
Project-coupled tools in groups
[dependency-groups]
dev = ["pytest>=8", "pytest-cov>=5"]
lint = ["mypy>=1.11"]
docs = ["mkdocs-material>=9", "mkdocs-click>=0.8"]
uv run pytest
uv run --group lint mypy src
Groups keep these tools out of the dependencies users install, while letting them see exactly the dependency versions in uv.lock — see locking and syncing CLI dependencies with uv.
Version-pinned runs with uvx
uvx ruff@0.16.10 check .
uvx --from 'pip-audit==2.10.1' pip-audit -r requirements.txt
uvx --python 3.12 --from cookiecutter cookiecutter gh:acme/cli-template
uvx (an alias for uv tool run) resolves the tool into a cached environment keyed by its requirements, so the first run downloads and later runs start instantly. Put the pinned commands in a Makefile, justfile or task runner so nobody types the version by hand — and keep them consistent with the pre-commit configuration, as discussed in keeping hook versions current with autoupdate.
Personal tools installed once
uv tool install httpie
uv tool install 'mytool[yaml]' --with mytool-aws-plugin # a tool plus a plugin
pipx install --suffix=@1 'mytool<2' # pipx: keep an old major version alongside
uv tool list --show-paths
--with adds packages into the tool's environment — the right way to add plugins to an isolated tool, as described in writing a plugin for an existing CLI. pipx's --suffix installs a second copy under a different command name (mytool@1), which is useful while migrating scripts between major versions.
Which environment did that command come from?
When behaviour differs between machines, the first question is which copy of a command ran. which gives a path, but not whether that path is a project environment, a pipx tool, a uv tool or the system. This small script answers it by looking for the pyvenv.cfg that every virtual environment has, plus the markers pipx and uv leave behind:
# scripts/which_env.py
"""Report which Python environment a command on PATH belongs to."""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
def environment_of(executable: Path) -> tuple[str, Path | None]:
# Check the path as found first (a venv's python is a symlink out of the venv),
# then the resolved target (tool managers symlink executables into ~/.local/bin).
for parent in [*executable.absolute().parents[:2], *executable.resolve().parents]:
if (parent / "pyvenv.cfg").is_file():
if (parent / "pipx_metadata.json").is_file():
return "pipx tool", parent
if (parent / "uv-receipt.toml").is_file():
return "uv tool", parent
return "virtual environment", parent
return "system or other", None
def main(names: list[str]) -> int:
status = 0
for name in names:
found = shutil.which(name)
if found is None:
print(f"{name}: not on PATH")
status = 1
continue
kind, env = environment_of(Path(found))
print(f"{name}: {found} -> {kind}" + (f" ({env})" if env else ""))
return status
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Checking both the path as found and its resolved target matters: a virtual environment's python is a symlink out of the environment to the base interpreter, while uv and pipx put symlinks into their tool environments on PATH. Run with an activated project environment, it reports something like:
python3: ~/src/mytool/.venv/bin/python3 -> virtual environment (~/src/mytool/.venv)
mytool: ~/src/mytool/.venv/bin/mytool -> virtual environment (~/src/mytool/.venv)
http: ~/.local/bin/http -> uv tool (~/.local/share/uv/tools/httpie)
UX considerations
- Write the homes down. A short table in
CONTRIBUTING.md— "pytest and mypy:uv run; Ruff:uvx ruff@…or pre-commit; everything else: your choice" — ends most "which version?" discussions. - Never require personal tools. If the project's scripts call
httporaws, either add them to a group or check for them and print an install hint; do not assume a developer installed them the same way you did. - Avoid activation-dependent behaviour.
uv runanduvxwork the same whether or not a virtual environment is activated, which makes instructions copy-pasteable. - Keep
~/.local/binearly onPATH, after any activated project environment, so tool installs work and project commands still win inside a project.
Testing the behaviour
The diagnostic script is worth a test because path resolution is subtle:
# tests/test_which_env.py
from pathlib import Path
from scripts.which_env import environment_of
def make_env(root: Path, marker: str | None = None) -> Path:
(root / "bin").mkdir(parents=True)
(root / "pyvenv.cfg").write_text("home = /usr/bin\n")
if marker:
(root / marker).write_text("")
exe = root / "bin" / "tool"
exe.write_text("#!/bin/sh\n")
return exe
def test_plain_venv(tmp_path):
exe = make_env(tmp_path / "venv")
assert environment_of(exe)[0] == "virtual environment"
def test_uv_tool_through_symlink(tmp_path):
exe = make_env(tmp_path / "tools" / "tool", "uv-receipt.toml")
link_dir = tmp_path / "bin"
link_dir.mkdir()
(link_dir / "tool").symlink_to(exe)
assert environment_of(link_dir / "tool")[0] == "uv tool"
def test_pipx_tool(tmp_path):
exe = make_env(tmp_path / "venvs" / "tool", "pipx_metadata.json")
assert environment_of(exe)[0] == "pipx tool"
def test_outside_any_env(tmp_path):
exe = tmp_path / "tool"
exe.write_text("")
assert environment_of(exe) == ("system or other", None)
Symlink creation needs extra privileges on some Windows setups; mark that test with a POSIX-only marker as in running CLI tests on Windows and macOS runners if your CI includes Windows.
Conclusion
Tools and projects should not share an environment by accident. Put tools that import your code in dependency groups and run them with uv run; run file-only tools with a pinned uvx command; install personal, cross-project tools with uv tool install or pipx; and when something behaves differently on one machine, find out which environment the command came from before debugging anything else.
Frequently asked questions
Should Ruff be a dev dependency or a uvx command?
Either works, as long as there is one version. A dev-group entry keeps it in the lock file and lets editors find it in the project environment; a pinned uvx command or pre-commit hook keeps the project environment smaller. Pick one and make CI use the same.
What is the difference between uvx and uv run?
uv run runs a command inside the project's environment, syncing it first. uvx runs a tool in a separate, cached environment that has nothing to do with the project. Use uv run for pytest and mypy, uvx for standalone tools.
Can I install a tool from a local checkout?
Yes: uv tool install --editable . or pipx install --editable . installs your own CLI as a tool that reflects source changes immediately — handy for dogfooding a CLI you are developing, without activating its project environment.
Why does a tool still pick up my project's packages?
Usually because the project environment is activated and the "tool" is actually the copy installed in it. Run the diagnostic script, or uv tool list --show-paths, to see which one is first on PATH.
How do I clean up tools I no longer use?
uv tool list and pipx list show what is installed; uv tool uninstall NAME and pipx uninstall NAME remove a tool and its whole environment, leaving nothing behind in system or project packages. For ephemeral uvx runs, uv cache prune removes cached environments that are no longer referenced — safe to run at any time, since uvx simply rebuilds what it needs.