Skip to content

pytest Assertion Error: PATH shortened interpreter name differs between venv and tox

Environment: CPython 3.12.13, pytest 9.0.2, face 26.0.x, venv created by uv venv at .venv/, macOS (same on Linux).

A pytest test that asserts on a PATH-shortened interpreter name passes under tox but fails when I run the venv's pytest directly. The test asserts face.utils.get_minimal_executable() == os.path.basename(sys.executable), i.e. that the help renderer prints python -m mypkg rather than a fully-qualified path.

Running .venv/bin/pytest -q gives:

E  AssertionError: assert '/abs/path/to/.venv/bin/python -m search_pkg' == 'python -m search_pkg'
E    - python -m search_pkg
E    + /abs/path/to/.venv/bin/python -m search_pkg

The identical test is green under tox and green in CI, so I first assumed my working-tree changes had broken it. git stash --include-untracked and re-running proved the failure is pre-existing and unrelated to any code change, which ruled that out.

Next I assumed the venv itself was broken or that sys.executable differed between the two invocations. It does not — both resolve to the same .venv/bin/python. I also assumed that executing a console script from <venv>/bin/ is equivalent to activating the venv first, since sys.executable, sys.prefix, and site-packages resolution are all identical either way. The pip and uv docs both describe running <venv>/bin/<tool> as the way to use a venv without activating, with no caveat attached. Nothing in the pytest output points at the environment, because the assertion diff only shows the path string.

1 solution
ranked by outcome — not votes
Accepted

Root cause: executing <venv>/bin/<script> does not put <venv>/bin on PATH. Only source <venv>/bin/activate mutates PATH (and sets VIRTUAL_ENV). A console script launched by absolute path gets the correct sys.executable and sys.prefix through its shebang, but inherits the parent shell's PATH untouched. tox (4.x), uv run, hatch run, and an activated shell all do prepend the env's bin directory — which is why the same test is green there and red locally.

Any code that answers "is this interpreter/tool reachable by bare name?" therefore flips behavior between the two invocation styles. Concretely, face.utils.get_minimal_executable() (face 26.0.x, unchanged in substance since 2020) walks os.environ['PATH'] and returns os.path.basename(executable) only when some PATH entry contains it:

executable_basename = os.path.basename(executable)   # 'python'
for p in path:                                       # os.environ['PATH'].split(os.pathsep)
    if os.path.relpath(executable, p) == executable_basename:
        return executable_basename                   # 'python'
return executable                                    # '/abs/path/to/.venv/bin/python'

With .venv/bin absent from PATH, the loop never hits, the full path is returned, and the assertion fails. shutil.which('python'), subprocess.run(['python', ...]), and anything shelling out to a bare tool name share the failure mode — they either miss or, worse, silently pick up the system interpreter.

Verify it in one line (CPython 3.12.13):

.venv/bin/python -c "import os,sys; print(os.path.dirname(sys.executable) in os.environ['PATH'].split(os.pathsep))"
# False
source .venv/bin/activate && python -c "import os,sys; print(os.path.dirname(sys.executable) in os.environ['PATH'].split(os.pathsep))"
# True

Fixes, in order of preference:

  1. Run the suite the way CI does, so local and CI agree: tox, or uv run pytest, or activate first. Ad hoc: PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest -q — this alone turned 1 failed / 73 passed into 74 passed, with no code change.
  2. Make the test hermetic rather than ambient. get_minimal_executable accepts explicit path= and environ= precisely so it need not read the real environment: get_minimal_executable(executable='/x/y/python', path=['/x/y']) == 'python'. If the API under test has no such seam, monkeypatch.setenv('PATH', os.path.dirname(sys.executable) + os.pathsep + os.environ['PATH']).
  3. In production code, never shell out to a bare python/pip. Use sys.executable (and [sys.executable, '-m', 'pip']), which is correct regardless of PATH.

The general rule worth internalizing: a virtualenv is PATH state, not just interpreter state. sys.executable tells you which interpreter is running; it tells you nothing about what a bare name resolves to. Tests that depend on the second thing must set PATH explicitly or inject it.