Project Setup

Managing Python CLI Virtual Environments

Solve cross-platform venv activation, PATH resolution, and interpreter discovery for Python CLI tools on Linux, macOS, and Windows with uv and pyenv.

Updated

A virtual environment looks the same in your pyproject.toml on every platform, but on disk it is not the same shape. The directory that holds your interpreter and console scripts is bin/ on Linux and macOS and Scripts/ on Windows; the activation script comes in four flavours; and the shebang line that makes a wrapper executable on POSIX means nothing on Windows. If your CLI's setup docs assume one platform, half your contributors hit a wall on the first command. This guide shows how to manage venvs so the same instructions work everywhere — and how to write code that discovers its own environment without guessing.

TL;DR

  • The interpreter lives in bin/ on POSIX, Scripts/ on Windows. Never hardcode either — ask sysconfig.get_path("scripts").
  • Activation scripts differ per shell: activate (bash/zsh), activate.fish, Activate.ps1 (PowerShell). In scripts and CI, skip activation entirely and call .venv/bin/python / .venv\Scripts\python.exe directly.
  • Prefer python -m yourtool over relying on a shebang — shebangs are POSIX-only.
  • Use uv or pyenv to pin one interpreter version across all three OSes so "3.12" means the same build everywhere.

Two side-by-side venv trees: on Linux/macOS the .venv/bin/ folder holds python, activate, and mytool; on Windows the .venv\Scripts\ folder holds python.exe, activate.bat, and mytool.exe — the differing bin vs Scripts folder is highlighted.

The directory layout differs by platform

Create a venv and the structure depends on the OS:

# Linux / macOS
python -m venv .venv
.venv/
├── bin/
   ├── python        -> symlink to the interpreter
   ├── pip
   ├── activate      # bash / zsh
   ├── activate.fish # fish
   └── yourtool      # your console_scripts entry point
├── lib/
   └── python3.12/site-packages/
└── pyvenv.cfg
# Windows (PowerShell)
py -m venv .venv
.venv\
├── Scripts\
│   ├── python.exe
│   ├── pip.exe
│   ├── activate.bat   # cmd.exe
│   ├── Activate.ps1   # PowerShell
│   └── yourtool.exe   # your console_scripts entry point
├── Lib\
│   └── site-packages\
└── pyvenv.cfg

Two consequences for cross-platform tooling: the executable directory name changes (bin vs Scripts), and Windows wrappers carry a .exe suffix while POSIX ones don't. Any code or Makefile that joins ".venv/bin/python" is already broken on Windows.

Activation across shells

Activation just prepends the venv's script directory to PATH and sets a few environment variables for the current shell session. The script you source depends on your shell:

# bash / zsh (Linux, macOS)
source .venv/bin/activate

# fish
source .venv/bin/activate.fish
# PowerShell (Windows, and PowerShell Core on macOS/Linux)
.venv\Scripts\Activate.ps1

# cmd.exe
.venv\Scripts\activate.bat

If Activate.ps1 fails with an execution-policy error, allow signed local scripts for the current user once:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

Activation is a developer convenience, not a deployment mechanism. It mutates this shell's PATH, and that change does not propagate into subprocesses, Makefile recipes, or CI steps that spawn a new shell. The robust alternative is to never activate.

Skip activation: call the interpreter directly

The most portable way to run a tool from its environment is to invoke the environment's own Python and let the -m flag find your package:

# POSIX — no activation, works in scripts and CI
.venv/bin/python -m yourtool --help
# Windows — same idea, different path
.venv\Scripts\python.exe -m yourtool --help

Better still, let uv resolve the environment for you so you don't branch on the path at all. uv run finds (or creates) the project's venv and runs the command inside it on every platform identically:

uv run yourtool --help     # identical command on Linux, macOS, Windows
uv run python -m yourtool  # or invoke the module directly

Shebang vs python -m

On POSIX, a console-script wrapper starts with a shebang pointing at the venv's interpreter, e.g. #!/path/to/.venv/bin/python. That is what lets you type yourtool after activation. But shebangs are a kernel feature that Windows ignores entirely — there, the .exe launcher does the equivalent job. So a script that shells out with a hardcoded #!/usr/bin/env python3 will behave differently across platforms, and one that assumes the shebang is honoured won't run on Windows at all.

The portable rule: don't depend on the shebang for cross-platform execution. Invoke modules with python -m yourtool. The -m form uses the current interpreter, so it runs against the right environment whether you activated it or called its Python directly. Reserve the console-script entry point for the end-user convenience case where pipx/uv tool install put a launcher on PATH for them.

Interpreter discovery from inside your CLI

Sometimes your CLI needs to find its own environment — to locate a sibling console script, write a config next to the interpreter, or spawn a subprocess with the same Python. Hardcoding bin/ breaks on Windows; the standard library tells you the truth instead. sysconfig.get_path("scripts") returns bin on POSIX and Scripts on Windows, and sys.prefix != sys.base_prefix reliably detects whether you're inside a venv:

r"""Locate a virtual environment's executable directory cross-platform.

Works on Linux, macOS (bin/) and Windows (Scripts/) without hardcoding
the directory name. Run with the interpreter you want to inspect:

    path/to/.venv/bin/python introspect.py         # POSIX
    path\to\.venv\Scripts\python.exe introspect.py  # Windows
"""

from __future__ import annotations

import sys
import sysconfig
from pathlib import Path


def in_virtualenv() -> bool:
    # base_prefix differs from prefix only inside a venv/virtualenv.
    return sys.prefix != sys.base_prefix


def scripts_dir() -> Path:
    # sysconfig knows the right layout for this interpreter and platform:
    # 'Scripts' on Windows, 'bin' on POSIX. Never hardcode either name.
    return Path(sysconfig.get_path("scripts"))


def console_script(name: str) -> Path:
    exe = scripts_dir() / name
    # Console-script wrappers gain a .exe suffix on Windows.
    if sys.platform == "win32":
        exe = exe.with_suffix(".exe")
    return exe


def main() -> None:
    print(f"interpreter      : {sys.executable}")
    print(f"version          : {sys.version.split()[0]}")
    print(f"platform         : {sys.platform}")
    print(f"in virtualenv    : {in_virtualenv()}")
    print(f"sys.prefix       : {sys.prefix}")
    print(f"sys.base_prefix  : {sys.base_prefix}")
    print(f"scripts dir      : {scripts_dir()}")
    print(f"this CLI wrapper : {console_script('mytool')}")


if __name__ == "__main__":
    main()

Run against an isolated interpreter, this prints something like:

interpreter      : /tmp/cli-validate/bin/python
version          : 3.14.4
platform         : linux
in virtualenv    : True
sys.prefix       : /tmp/cli-validate
sys.base_prefix  : /usr
scripts dir      : /tmp/cli-validate/bin
this CLI wrapper : /tmp/cli-validate/bin/mytool

On Windows the same code prints scripts dir : ...\Scripts and the wrapper as mytool.exe — without a single platform branch in the discovery logic, because sysconfig already encodes the platform's layout. When you need to spawn a subprocess with the same interpreter, use sys.executable rather than the bare name python, which may resolve to a different install on PATH.

Consistent interpreters with uv and pyenv

Layout differences are mechanical; version drift is insidious. If a contributor on macOS runs 3.11 and CI runs 3.12, you get bugs that reproduce on exactly one machine. Pin the interpreter so every platform agrees:

# uv: download and pin a specific CPython, then build the env from it
uv python install 3.12
uv venv --python 3.12      # creates .venv using that exact interpreter
uv sync                    # install dependencies from the lockfile
# pyenv (Linux/macOS; use pyenv-win on Windows): pin per-project
pyenv install 3.12.4
pyenv local 3.12.4         # writes .python-version
python -m venv .venv       # the venv inherits 3.12.4

uv is the lower-friction option on CI and Windows because it ships a single static binary and manages interpreters itself; pyenv is well established on developer machines but needs pyenv-win (a separate project) for Windows. Either way, commit the version pin (requires-python plus the lockfile for uv, or .python-version for pyenv) so the environment is reproducible rather than ambient. See uv for Python CLI dependency management for the full lockfile workflow.

Production notes

  • Don't commit the venv. Add .venv/ to .gitignore. The environment is derived from the lockfile and is platform-specific; checking it in guarantees breakage on the next OS. Commit the lockfile instead.
  • PATH ordering wins ties. After activation the venv directory is first, so yourtool resolves there. Outside activation, a globally installed shim can shadow it — another reason to invoke sys.executable or uv run explicitly in automation.
  • CI caching can fight fresh environments. Caching .venv across runs can serve a stale or wrong-platform environment; cache the download/wheel directory or uv's cache, not the activated env, and recreate .venv from the lockfile each run.
  • Line endings break POSIX wrappers. If your repo ships shell wrapper scripts, CRLF endings make them unrunnable on Linux/macOS. Add * text=auto eol=lf to .gitattributes so wrappers stay LF regardless of who clones on Windows.