Project Setup

Debugging Wrong-Python and Wrong-Venv Problems in CLIs

Diagnose ModuleNotFoundError, stale code and wrong versions caused by the wrong interpreter: PATH order, shell hash caches, broken shebangs, PYTHONPATH and a doctor command.

Updated

"It is installed, but Python says ModuleNotFoundError." "I fixed the bug, but the command still does the old thing." "mytool --version says 1.4 but pip says 1.6." Almost every one of these reports has the same root cause: the command that ran is not the one the person thinks it is. There are several interpreters on a typical developer machine — the system Python, Homebrew or pyenv versions, uv-managed ones — and several environments built from them: project virtual environments, pipx and uv tool environments, the user site. PATH order, shell caches and shebang lines decide which one a command uses, and each can be stale. This guide gives a systematic way to find out which interpreter and environment are actually involved, explains the usual culprits, and ends with a doctor command you can add to your own CLI so users can answer the question for you. It belongs to the virtual environments topic.

Prerequisites

How a command finds its interpreter

When you type mytool, three lookups happen, and each can go wrong:

From a typed command to an import How a command resolves to code: the shell searches PATH or its hash cache, the shebang names an interpreter, and the interpreter builds sys.path and imports the first match. From a typed command to an import Shell PATH order, hash cache Shebang absolute venv path Interpreter sys.prefix sys.path first match wins finds starts builds Each step can be stale — and each can be checked with one command.
  1. The shell finds an executable by walking PATH left to right — unless it remembered an earlier answer in its hash table, in which case it does not look again.
  2. The executable's shebang (#!/path/to/venv/bin/python) names the interpreter. Console scripts created by pip, uv and pipx hard-code the absolute path of the environment's Python at install time.
  3. That interpreter builds sys.path from its own site-packages, the user site (unless disabled), .pth files and PYTHONPATH, and imports the first matching module it finds.

A ModuleNotFoundError means step 3 ran in an environment without the package. Stale behaviour usually means step 3 found an older copy first. A wrong version means step 1 or 2 picked a different environment than the one you upgraded.

The recipe: four questions, in order

1. Which executable runs?

type -a mytool python python3          # bash/zsh: every match on PATH, in order
command -v mytool                      # the one that will actually run
hash -r                                # bash/zsh: forget remembered command locations

type -a shows every candidate, which immediately reveals a second copy shadowing the one you installed — for example ~/.local/bin/mytool from pipx ahead of .venv/bin/mytool. After installing or activating something, run hash -r (zsh also has rehash): bash and zsh cache command locations and may keep running the old path. In PowerShell, Get-Command mytool -All does the same job; fish has type -a and does not cache.

2. Which interpreter does it use?

head -1 "$(command -v mytool)"

If that path no longer exists — the environment was deleted, moved or recreated with a different Python — you get the confusing "bad interpreter: No such file or directory" error, or on some systems a "command not found" for a file that plainly exists. Virtual environments cannot be moved or renamed; recreate them instead (rm -rf .venv && uv sync). Tools installed by uv sometimes use a #!/bin/sh launcher line instead of a direct shebang when the path is long; follow the symlink with readlink -f "$(command -v mytool)" and look at the environment it points into.

3. Which environment and which copy of the package?

Ask the interpreter itself, not pip — pip on PATH may belong to yet another environment:

python -c "import sys; print(sys.executable); print(sys.prefix); print(sys.base_prefix)"
python -m pip show mytool               # pip of *this* interpreter
python -c "import mytool, importlib.metadata as m; print(mytool.__file__, m.version('mytool'))"

sys.prefix != sys.base_prefix means you are inside a virtual environment. mytool.__file__ shows exactly which copy was imported — a path into your source tree means an editable install, a path into site-packages means a regular one. If the file path and the metadata version disagree, there are two copies on sys.path.

4. What else is on sys.path?

python -m site                          # sys.path, user site and whether it is enabled
env | grep -E '^(PYTHONPATH|PYTHONHOME|VIRTUAL_ENV)='

A leftover PYTHONPATH from a shell profile or IDE run configuration puts a directory ahead of site-packages, so an old checkout of the package silently wins. PYTHONHOME set by an installer can point the interpreter at the wrong standard library entirely. VIRTUAL_ENV set while the matching bin/ is not first on PATH (a half-activated environment, common after cd-ing between projects) misleads tools that trust the variable.

Symptom to culprit Common wrong-interpreter symptoms in Python command line tools, their usual cause and the command that confirms it. Symptom to culprit Symptom Usual cause Confirm with ModuleNotFoundError pip and python differ python -m pip show Old behaviour after a fix second copy earlier module.__file__ Wrong --version other install first on PATH type -a mytool bad interpreter venv moved or deleted head -1 $(command -v …) Works only in one shell PYTHONPATH or hash cache python -m site; hash -r Check in this order and the mystery usually ends at the second question.

Give your CLI a doctor command

Users rarely know these commands, and support threads spend days on them. A doctor (or version --verbose) command that prints the answers turns that into one paste:

# src/mytool/doctor.py
from __future__ import annotations

import os
import platform
import sys
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path


def environment_kind() -> str:
    prefix = Path(sys.prefix)
    if (prefix / "pipx_metadata.json").exists():
        return "pipx"
    if (prefix / "uv-receipt.toml").exists():
        return "uv tool"
    if getattr(sys, "frozen", False):
        return "frozen binary"
    if sys.prefix != sys.base_prefix:
        return "virtual environment"
    return "system interpreter"


def report(dist_name: str = "mytool") -> dict[str, str]:
    try:
        dist = distribution(dist_name)
        version = dist.version
        location = str(dist.locate_file(""))
    except PackageNotFoundError:
        version, location = "not installed (running from source?)", "-"
    module = sys.modules.get(dist_name.replace("-", "_"))
    return {
        "mytool version": version,
        "imported from": getattr(module, "__file__", None) or "-",
        "installed at": location,
        "python": f"{platform.python_version()} ({sys.executable})",
        "environment": f"{environment_kind()} at {sys.prefix}",
        "platform": platform.platform(),
        "PYTHONPATH": os.environ.get("PYTHONPATH", "(not set)"),
    }


def render(info: dict[str, str]) -> str:
    width = max(map(len, info))
    return "\n".join(f"{key:<{width}}  {value}" for key, value in info.items())

Wire it to a command — @app.command() def doctor(): typer.echo(render(report())) — and add "please paste the output of mytool doctor" to your bug report template. The "imported from" line alone resolves most stale-code reports, and the environment line resolves most "wrong version" ones. Keep anything sensitive out: no environment variables beyond PYTHONPATH, no tokens, no config contents. Exposing version info and build metadata covers the version side in more depth.

One paste answers it all Terminal session showing a doctor command reporting the tool version, where it was imported from, the interpreter and the environment kind. One paste answers it all bash $ mytool doctor mytool version 1.6.0 imported from ~/src/mytool/src/mytool/__init__.py python 3.12.3 (~/src/mytool/.venv/bin/python) environment virtual environment at ~/src/mytool/.venv PYTHONPATH (not set) "imported from" pointing into a source tree means an editable install is the one running.

UX considerations

  • Prefer python -m in instructions. python -m pip install … and python -m mytool tie the command to a specific interpreter; bare pip does not.
  • Recommend uv run and uvx in docs for running project and tool commands; they choose the right environment without activation.
  • Print the interpreter on unexpected import errors. A top-level handler that adds "running under /usr/bin/python3; is mytool installed for this interpreter?" to a ModuleNotFoundError saves a round of questions.
  • Never auto-repair environments. A CLI that pip-installs missing modules into whatever interpreter it finds makes the problem worse; diagnose and tell the user what to run.

Testing the behaviour

The doctor report is ordinary code and deserves a test that pins the fields support depends on:

# tests/test_doctor.py
import sys

from mytool import doctor


def test_report_has_the_fields_support_asks_for():
    info = doctor.report("pytest")             # any installed distribution works here
    for key in ("mytool version", "python", "environment", "PYTHONPATH"):
        assert key in info
    assert sys.executable in info["python"]


def test_environment_kind_detects_venv(monkeypatch, tmp_path):
    monkeypatch.setattr(sys, "prefix", str(tmp_path / "venv"))
    monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base"))
    assert doctor.environment_kind() == "virtual environment"


def test_missing_distribution_is_reported_not_raised():
    assert "not installed" in doctor.report("definitely-not-installed-xyz")["mytool version"]


def test_render_aligns_columns():
    out = doctor.render({"a": "1", "longer key": "2"})
    assert out.splitlines() == ["a           1", "longer key  2"]

Conclusion

Wrong-interpreter problems feel mysterious because the failing piece is invisible. Make it visible in order: which executable the shell picks (type -a, hash -r), which interpreter its shebang names, which environment and which copy of the package that interpreter imports (sys.prefix, module.__file__, python -m pip), and what else is on sys.path (python -m site, PYTHONPATH). Then build the same questions into a doctor command, so users can hand you the answers in one paste.

Frequently asked questions

Why does pip install succeed but import fail?

Because pip and python belong to different environments. python -m pip install … uses the pip of the interpreter you will import with, which removes the mismatch.

My editable install shows old code — why?

Either a second, non-editable copy is earlier on sys.path (check module.__file__), or the change is in something that needs reinstalling, such as a new entry point or data file declared in pyproject.toml. Re-run uv sync or the editable install after packaging changes.

Is activating a virtual environment necessary?

No. Activation only puts its bin/ first on PATH and sets VIRTUAL_ENV. Running .venv/bin/mytool, uv run mytool or .venv/bin/python -m mytool uses the environment directly and avoids half-activated states.

Why does my CLI work in the terminal but not from cron or an IDE?

Those start with a different PATH and none of your shell's activation. Use absolute paths to the environment's executable in cron jobs, as in running a CLI on a schedule with cron and systemd, and point the IDE at the project interpreter explicitly.