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.
Same venv, different layout — bin/ on POSIX, Scripts\ on Windows Same venv, different layout Linux / macOS .venv/ bin/ python activate mytool interpreter shell hook entry point Windows .venv\ Scripts\ python.exe activate.bat mytool.exe interpreter shell hook entry point bin vs Scripts don't hardcode the folder — ask sysconfig.get_path("scripts")

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:

Activating an environment, per shell The activation command for a virtual environment in bash and zsh, fish, PowerShell and the Windows command prompt. Activating an environment, per shell Shell Command bash / zsh source .venv/bin/activate fish source .venv/bin/activate.fish PowerShell .venv\Scripts\Activate.ps1 cmd.exe .venv\Scripts\activate.bat Any of them …or skip activation entirely Scripts should never depend on activation: calling .venv/bin/python directly works in every shell above.
# 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:

Finding the interpreter that is running you A running CLI can find its own interpreter through sys.executable, and the environment it lives in through sys.prefix, without guessing at PATH. Finding the interpreter that is running you Your command runs inside some environment sys.executable the exact interpreter path Subprocess call it, never bare "python" ask Python reuse it Spawning "python" hopes PATH agrees with you; spawning sys.executable knows it does.
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.

Frequently asked questions

Why is it bin on Linux and Scripts on Windows?

It is a platform convention baked into the venv module, not something a project can configure. That single difference is why activation snippets in READMEs break for Windows users, and why any script that hard-codes .venv/bin/python is portable right up until someone runs it on Windows.

How do I avoid activation entirely?

Call the interpreter by path. .venv/bin/python -m mytool on Unix, .venv\Scripts\python.exe -m mytool on Windows — or let a tool do it for you with uv run or poetry run, which pick the right path per platform. Scripts that never activate are also scripts that never depend on shell state.

My CLI needs to spawn Python — what should it call?

sys.executable, always. It is the exact interpreter running your code, so the subprocess lands in the same environment with the same dependencies. Spawning a bare python hopes that PATH agrees, which it will not inside a pipx-installed tool, a frozen binary or a CI container with several interpreters.

Should the virtual environment be committed or cached?

Never committed — it contains absolute paths and platform-specific binaries. Cache it in CI instead, keyed on a hash of the lockfile, so a run either reuses an exactly matching environment or builds a fresh one. That key is what makes a stale cache impossible rather than merely unlikely.

Does the shebang in a console script cause cross-platform problems?

On Unix the generated shim carries a shebang with the absolute path of the environment's interpreter, which is why moving or renaming a virtual environment breaks its commands. Windows uses a small launcher executable instead. Neither is worth fighting: re-create the environment rather than relocating it.