# Virtual environments How we keep a project's Python isolated — from the system Python and from every other project. The rule underneath all of it: **never install into, or upgrade, the system Python.** The OS depends on it; a project must never touch it. !!! danger "Leave the system Python alone" No `sudo pip install`, no upgrading the system interpreter for a project. If a project needs a different version or a package, that goes in a **virtual environment** — never the system one. Breaking system Python can break the OS. ## Project-based isolation Every project runs against its own isolated environment. There are three ways that happens, depending on where the project runs: === "Local `.venv`" A virtualenv living in the repo. Simplest for day-to-day local work. ```bash python -m venv .venv # create it (once) source .venv/bin/activate # activate for this shell pip install -e . # install the project from pyproject.toml ``` Or with [uv](#uv-optional-faster) — same `pyproject.toml`, much faster: ```bash uv venv # create .venv uv pip install -e . # install from pyproject.toml ``` Keep `.venv/` **gitignored** — it's per-machine, never committed. === "Makefile-driven" Wrap the venv in a `make` target so every dev (and CI) sets up identically — no "did you activate it?" drift. ```makefile VENV := .venv PY := $(VENV)/bin/python $(VENV): requirements.txt python -m venv $(VENV) $(PY) -m pip install -r requirements.txt .PHONY: run run: $(VENV) $(PY) -m yourapp ``` `make run` creates the venv if missing, installs deps, and runs — all against the isolated interpreter, no manual activation. === "Docker" The container **is** the isolation — its own filesystem, its own interpreter, nothing shared with the host. You don't need a `.venv` inside an image; install straight into the container's Python. ```dockerfile FROM python:3.12-slim COPY pyproject.toml . RUN pip install --no-cache-dir . # or: uv pip install . COPY . . ``` This is how things run in production — see the [Deploy guide](deploy.md) for the full container standard (uid 1337, mounts, layer caching, and the uv image setup). !!! tip "Which one?" **Local `.venv`** for quick iteration, **Makefile** when you want repeatable setup across the team, **Docker** for anything that ships. They're not exclusive — a project often has a `.venv` for local dev *and* a Dockerfile for deploy. ## uv (optional, faster) !!! tip "uv is the recommended fast path; pip stays the baseline" [uv](https://docs.astral.sh/uv/) is a faster, standards-compliant drop-in for pip. It reads the **same `pyproject.toml`** — no workflow change required, and `pip` keeps working exactly as before. Use it wherever you'd reach for pip; the rest of this handbook shows the pip command with the uv equivalent beside it. Install deps straight from `pyproject.toml` (no `requirements.txt` needed): ```bash uv pip install . # install the project + its deps uv pip install -e . # editable (dev) install uv pip install '.[dev]' # with an extras group, e.g. dev ``` If you'd rather have uv manage the venv for you, use the managed-venv flow: ```bash uv sync # create/refresh .venv from pyproject.toml + uv.lock uv run python -m yourapp # run inside the managed env, no manual activate ``` !!! note "`uv.lock` is local-only — never committed" `uv sync` writes a `uv.lock` for your machine's resolved environment. It is **gitignored**, not committed — we don't ship a lockfile. Pinning happens in `pyproject.toml` (below), not the lock. ### Pinning deps Pin **direct** dependencies in `pyproject.toml` with `==` or a git `@ref`: ```toml [project] dependencies = [ "requests==2.31.0", "mylib @ git+https://git.rethinkstudios.io/rethink-public/mylib.git@", ] ``` !!! warning "This pins direct deps only — transitive deps still float" `==` / `@ref` pins the packages **you** list. Their dependencies still resolve fresh at build time. That's an accepted tradeoff — documented on purpose — not an oversight: we pin what we depend on directly and let the rest float. ## Local dev with pyenv For local work you also need the right **Python version**, not just isolated deps. We target **Python 3.10+**, and [pyenv](https://github.com/pyenv/pyenv) installs and switches versions per-project without touching the system Python. - **Per-project pinning:** a `.python-version` file in a repo makes pyenv auto-select that interpreter when you `cd` in — everyone on the project runs the same one. - **Pairs with venvs:** combined with `pyenv-virtualenv`, each project gets both an isolated version *and* isolated deps. ### Install Follow the [official pyenv installation](https://github.com/pyenv/pyenv#installation) for the installer and build dependencies — no point reproducing it here. Then add the shell init below to your `~/.zshrc` (or `~/.bashrc`) and restart your shell. ### Shell init Without these lines pyenv's shims aren't on `PATH`, so `pyenv` and auto-version-switching won't work: ```bash # pyenv — Python version management export PYENV_ROOT="$HOME/.pyenv" export PATH="$PYENV_ROOT/bin:$PATH" eval "$(pyenv init --path)" # (1)! eval "$(pyenv init -)" # (2)! eval "$(pyenv virtualenv-init -)" # (3)! ``` 1. Puts the **shims** dir on `PATH`, so `python` resolves to the pyenv-selected version instead of the system one. 2. Shell integration — command rehashing and completion. 3. Auto-activates a project's virtualenv on `cd` — **only** if you use `pyenv-virtualenv`. Drop this line if you don't. ### Everyday use ```bash pyenv install 3.10.14 # install a version (one-time) pyenv install --list # see available versions cd pyenv local 3.10.14 # writes .python-version -> auto-selects here python --version # confirms the pinned version ``` - **`pyenv local `** per project — commit the `.python-version` so the team matches. - **`pyenv global `** for your default outside any project. ## Shell quality-of-life extras A couple of optional lines worth having alongside pyenv: ```bash # flake8 with our shared config (max line 120) alias flake8='flake8 --config ~/.config/flake8' # local bins on PATH (pip --user installs, npm globals) export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.npm-global/bin:$PATH" ``` - The **flake8 alias** keeps everyone linting with the same config (our 120 max-line, etc. — see [Standards](standards.md#files-and-style)). - The **`.local/bin` / npm-global** PATH lines stop "command not found" after a `pip install --user` or a global npm install. !!! note "More shell setup" This is the Python-env slice. The fuller dev shell setup — the WSL/Windows clipboard bridges, per-repo git-identity aliases, and the `gl` graph log — is on the [Workflow](workflow.md#handy-shell-setup) page.