Project Setup

Isolating CLI Tools from Your Project’s Dependencies

Decide where each Python tool belongs: project dependency groups, pinned uvx runs, or isolated pipx and uv tool installs — plus a script that shows which environment a command comes from.

Updated

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

Three homes for a tool Where Python command line tools belong: project dependency groups, pinned ephemeral runs, or isolated personal installs. Three homes for a tool Dependency group uv run needs to import the project — pytest, mypy, docs generators Pinned ephemeral run uvx tool@x.y reads files, version matters — Ruff, pip-audit, formatters Isolated install uv tool / pipx personal, cross-project — httpie, cloud CLIs, cookiecutter One question decides it: does the tool need to import your code?

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.

Each tool from its own home Terminal session running pytest from the project environment, a pinned Ruff with uvx, and httpie from an isolated tool install. Each tool from its own home bash $ uv run pytest -q 128 passed in 4.2s $ uvx ruff@0.16.10 check . All checks passed! $ python scripts/which_env.py http http: ~/.local/bin/http -> uv tool (~/.local/share/uv/tools/httpie) None of these touch the others’ environments.

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 http or aws, 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 run and uvx work the same whether or not a virtual environment is activated, which makes instructions copy-pasteable.
  • Keep ~/.local/bin early on PATH, after any activated project environment, so tool installs work and project commands still win inside a project.
Where should this tool live? A decision guide for placing a Python tool in a dependency group, a pinned uvx command or an isolated tool install. Where should this tool live? Does the tool import your project? Yes Group uv add --dev No, but the version matters uvx pinned in a task file No, it is personal Tool install uv tool / pipx Projects may depend on the first two homes, never on the third.

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.