Virtual environments
Lesson 15 — Python
Learning objectives
- Explain why environment isolation is not optional in Python
- Create and manage environments with venv, uv and conda
- Pin dependencies with a lockfile
- Structure a project with
pyproject.toml - Reproduce an environment exactly on another machine
Why this matters more in Python than in R
R installs packages into a shared library and, for the most part, packages coexist. Python’s dependency resolution is stricter: two projects needing different versions of the same package genuinely cannot share an installation.
Without isolation:
pip install pandas==1.5 # for the old study
pip install pandas==2.2 # for the new one — overwrites the firstThe old study now silently runs on pandas 2.2, where applymap is deprecated and copy-on-write semantics differ. This is renv’s argument, but the failure mode is more immediate.
venv — the standard library
python -m venv .venv
source .venv/bin/activate # macOS / Linux
.venv\Scripts\activate # Windows
which python # /path/to/project/.venv/bin/python
pip install pandas numpy
pip list
pip freeze > requirements.txt
deactivateAlways create the environment inside the project as .venv, and add it to .gitignore. Editors and tools discover .venv automatically.
# .gitignore
.venv/
__pycache__/
*.pyc
.pytest_cache/
.coverage
uv — the modern tool
uv is a Rust reimplementation of pip and venv that is 10–100× faster and handles locking properly. It has become the default recommendation.
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv # create .venv
uv pip install pandas numpy # install, very fast
uv pip compile requirements.in -o requirements.txt # lock
uv pip sync requirements.txt # install EXACTLY the lockfile
# Project management
uv init myproject
uv add pandas
uv add --dev pytest ruff
uv run pytest # runs in the project environment
uv lock # write uv.lock
uv sync # reproduce from uv.lockuv sync is the equivalent of renv::restore(). The difference from pip install -r requirements.txt is that sync also removes packages not in the lockfile, so the environment matches exactly rather than being a superset.
conda and mamba
Manages non-Python dependencies too — compilers, CUDA, system libraries. The right choice for scientific computing with binary dependencies.
conda create -n study python=3.11
conda activate study
conda install pandas numpy scipy -c conda-forge
conda env export > environment.yml
conda env create -f environment.yml
mamba create -n study python=3.11 # much faster resolver# environment.yml
name: study
channels:
- conda-forge
dependencies:
- python=3.11
- pandas=2.2
- numpy=1.26
- pyreadstat=1.2
- pip
- pip:
- shiny==0.10.2uv— the default for new projects. Fast, correct locking, actively developed.venv+pip— no installation needed, universally available.conda/mamba— when you need non-Python dependencies, or on a platform where wheels are unavailable.
Do not mix them in one project. Mixing conda install and pip install in the same environment is a documented source of broken installations.
Dependency specification
# requirements.txt — direct dependencies, loosely pinned
pandas>=2.0,<3.0
numpy>=1.24
pyreadstat>=1.2
plotly>=5.18
# requirements-dev.txt
-r requirements.txt
pytest>=7.4
pytest-cov
ruff
mypy
pip install -r requirements-dev.txtVersion specifiers
pandas==2.2.0 exactly
pandas>=2.0 at least
pandas>=2.0,<3.0 a range
pandas~=2.2.0 compatible: >=2.2.0, <2.3.0
pandas!=2.1.0 exclude a known-bad version
Use ranges in requirements.in (what you need) and exact pins in requirements.txt (what you tested). That distinction is the whole point of locking.
pyproject.toml
The modern single configuration file for the whole project.
[project]
name = "study-abc101"
version = "1.2.0"
description = "Analysis programs for study ABC-101"
requires-python = ">=3.11"
authors = [{name = "Ram Gaduputi", email = "ram@example.com"}]
dependencies = [
"pandas>=2.0,<3.0",
"numpy>=1.24",
"pyreadstat>=1.2",
"plotly>=5.18",
]
[project.optional-dependencies]
dev = ["pytest>=7.4", "pytest-cov", "ruff", "mypy"]
app = ["shiny>=0.10", "shinywidgets"]
[project.scripts]
run-adam = "study.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers --cov=study"
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM", "PD"]
# E/F pycodestyle+pyflakes, I isort, N naming, UP pyupgrade,
# B bugbear, SIM simplify, PD pandas-vet
[tool.mypy]
python_version = "3.11"
warn_return_any = true
disallow_untyped_defs = truepip install -e . # editable install of the project
pip install -e ".[dev]" # with the dev extrasOne file replaces setup.py, setup.cfg, requirements.txt, pytest.ini, .flake8 and mypy.ini.
Locking
The distinction that matters:
| File | Contains | Purpose |
|---|---|---|
pyproject.toml / requirements.in |
Direct dependencies, ranges | What you need |
requirements.txt / uv.lock |
Every package, exact versions, hashes | What you tested |
# pip-tools
pip install pip-tools
pip-compile requirements.in -o requirements.txt
pip-sync requirements.txt
# uv (faster, same idea)
uv pip compile requirements.in -o requirements.txt
uv pip sync requirements.txt
# uv project workflow
uv lock
uv syncA generated lockfile:
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.in -o requirements.txt
numpy==1.26.4
# via
# pandas
# pyreadstat
pandas==2.2.2
# via -r requirements.in
python-dateutil==2.9.0.post0
# via pandas
pytz==2024.1
# via pandas
Every transitive dependency, exactly pinned, with the reason it is there. Commit it.
pip freeze is not a lockfile
pip freeze > requirements.txtThis records what happens to be installed — including packages you installed and forgot, editable installs with local paths, and nothing about why anything is there. It also cannot distinguish direct from transitive dependencies, so you can never safely remove anything.
Use pip-compile or uv lock, which take a declared set of direct dependencies and resolve them.
Hashes
For a regulated or security-sensitive environment:
uv pip compile requirements.in --generate-hashes -o requirements.txtpandas==2.2.2 \
--hash=sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54 \
--hash=sha256:1cb51fe389360f3b5a4d57dbd2848a5f033350336ca3b340d1c53a1fad33bcad
pip install then verifies the download against the hash, so a compromised or substituted package fails to install. This is what an archived, auditable environment requires.
Reproducing an environment
git clone https://github.com/org/study-abc101
cd study-abc101
uv venv
uv pip sync requirements.txt
uv run pytestOr with plain tooling:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pytestIt pins Python packages. It does not pin:
- The Python version —
python_requiresconstrains but does not fix it - The operating system and system libraries
- Compiler versions for anything built from source
- Locale, timezone, and environment variables
For a deliverable that must be reproducible in three years, you need the lockfile and a container image, exactly as the R lesson argues for renv plus Docker.
Containers
FROM python:3.11.9-slim
WORKDIR /app
# Install dependencies first — this layer caches
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Then the code — changes here do not reinstall packages
COPY src/ ./src/
COPY pyproject.toml .
RUN pip install --no-cache-dir -e . --no-deps
RUN useradd -m -u 1000 appuser && chown -R appuser /app
USER appuser
CMD ["python", "-m", "study.pipeline"]Faster, with uv:
FROM python:3.11.9-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY requirements.txt .
RUN uv pip install --system --no-cache -r requirements.txt
COPY src/ ./src/
CMD ["python", "-m", "study.pipeline"]Pin the base image tag (3.11.9-slim, not 3.11 or latest) or a rebuild in six months produces a different Python.
Project layout
study-abc101/
├── pyproject.toml
├── requirements.in
├── requirements.txt # locked, committed
├── .python-version # for pyenv / uv
├── .gitignore
├── README.md
├── Dockerfile
├── .venv/ # NOT committed
├── src/
│ └── study/
│ ├── __init__.py
│ ├── derivations.py
│ ├── validation.py
│ └── pipeline.py
├── tests/
│ ├── conftest.py
│ └── test_derivations.py
├── data/ # NOT committed
│ ├── raw/
│ └── derived/
├── notebooks/
└── output/ # NOT committed
The src/ layout matters: it forces you to install the package to import it, which means the tests exercise the installed package rather than the working directory. Bugs where a module works locally and fails on install are caught immediately.
R and Python side by side
| Task | R | Python |
|---|---|---|
| Isolated environment | renv::init() |
python -m venv .venv or uv venv |
| Install a package | install.packages("x") |
pip install x or uv add x |
| Record the state | renv::snapshot() |
uv lock / pip-compile |
| Restore the state | renv::restore() |
uv sync / pip-sync |
| Lockfile | renv.lock |
uv.lock / requirements.txt |
| Project config | DESCRIPTION |
pyproject.toml |
| Check state | renv::status() |
uv pip check |
| Update | renv::update() |
uv lock --upgrade |
| Activate | Automatic in the project | source .venv/bin/activate |
The main practical difference: renv activates automatically when you open the project, and Python environments must be activated explicitly. uv run and editor integration mostly hide this, but it is the source of the most common Python confusion — “I installed it and it says it is not installed” almost always means the wrong environment is active.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| No virtual environment | Version conflicts across projects | One .venv per project |
Committing .venv/ |
A huge, machine-specific repository | .gitignore |
pip freeze as a lockfile |
Unremovable cruft, no provenance | pip-compile / uv lock |
| Only loose version ranges | Different versions on different machines | Commit the lockfile |
| Mixing conda and pip | Broken installations | Pick one per environment |
latest base image |
Non-reproducible rebuilds | Pin the exact tag |
| Installing into the system Python | Breaks OS tooling | Always a virtual environment |
| Wrong environment active | “Module not found” for an installed package | which python |
Exercise 10.1 — Set up a reproducible project
Create a project structure for a clinical analysis with locked dependencies, dev tooling, a Dockerfile, and instructions a colleague can follow to reproduce it exactly.
Show solution
uv init study-abc101
cd study-abc101pyproject.toml
[project]
name = "study-abc101"
version = "1.0.0"
description = "Analysis programs for study ABC-101"
readme = "README.md"
requires-python = ">=3.11,<3.13"
authors = [{name = "Ram Gaduputi", email = "ram@example.com"}]
dependencies = [
"pandas>=2.0,<3.0",
"numpy>=1.24,<2.0",
"pyreadstat>=1.2",
"pyarrow>=15.0",
"plotly>=5.18",
"openpyxl>=3.1",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"ruff>=0.4",
"mypy>=1.9",
"pandas-stubs",
]
app = ["shiny>=0.10", "shinywidgets>=0.3"]
[project.scripts]
run-adam = "study.pipeline:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/study"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers --cov=study --cov-report=term-missing"
markers = ["slow: long-running tests"]
[tool.ruff]
line-length = 88
target-version = "py311"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM", "PD", "RUF"]
ignore = ["E501"] # line length handled by the formatter
[tool.mypy]
python_version = "3.11"
disallow_untyped_defs = true
warn_return_any = true
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = ["pyreadstat.*", "plotly.*"]
ignore_missing_imports = true
[tool.coverage.report]
fail_under = 85
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]Lock it
uv lock
uv sync --extra dev.python-version
3.11.9
.gitignore
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
# Patient data — never commit
data/raw/
data/derived/
data/submission/
*.sas7bdat
*.xpt
*.parquet
# Output — regenerable
output/
.ipynb_checkpoints/
.env
Dockerfile
FROM python:3.11.9-slim AS base
COPY --from=ghcr.io/astral-sh/uv:0.4.20 /uv /usr/local/bin/uv
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
# Dependency layer — caches unless the lockfile changes
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# Application layer
COPY src/ ./src/
RUN uv sync --frozen --no-dev
RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app
USER appuser
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "-m", "study.pipeline"]--frozen makes the build fail if uv.lock is out of date with pyproject.toml, rather than silently resolving something different. That is what makes the container reproducible.
Makefile
.PHONY: install test lint format check clean docker
install:
uv sync --extra dev
test:
uv run pytest
lint:
uv run ruff check .
uv run mypy src/
format:
uv run ruff format .
uv run ruff check --fix .
check: lint test
clean:
rm -rf .pytest_cache .mypy_cache .ruff_cache htmlcov .coverage
find . -type d -name __pycache__ -exec rm -rf {} +
docker:
docker build -t study-abc101:$$(grep '^version' pyproject.toml | cut -d'"' -f2) .README.md
# Study ABC-101 Analysis
## Reproducing this environment
Requires Python 3.11 and [uv](https://docs.astral.sh/uv/).
```bash
git clone https://github.com/org/study-abc101
cd study-abc101
uv sync --extra dev
uv run pytest
```
`uv sync` installs exactly the versions in `uv.lock` — including removing
anything not in it. The environment will be byte-identical to the one used to
produce the delivered output.
### Without uv
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
This resolves versions afresh and may differ from the lockfile. Use uv for
anything that must match a delivery.
## Running the pipeline
```bash
uv run run-adam --config config/study.yaml
```
## Reproducibility
| Component | Pinned by |
|---|---|
| Python packages | `uv.lock` |
| Python version | `.python-version`, `requires-python` |
| OS and system libraries | `Dockerfile` base image tag |
| Source code | Git tag |
For a delivery, record: the Git tag, the `uv.lock` hash, and the Docker image
digest.
```bash
git rev-parse HEAD
sha256sum uv.lock
docker inspect --format='{{index .RepoDigests 0}}' study-abc101:1.0.0
```Verification
# Does a clean clone actually reproduce?
git clone . /tmp/verify && cd /tmp/verify
uv sync --frozen
uv run pytest
uv pip list > /tmp/verify-packages.txt
diff /tmp/verify-packages.txt <(cd - && uv pip list)Recap
- One virtual environment per project, named
.venv, gitignored uvis the modern default;venv+pipalways works;condafor binary dependenciespip freezeis not a lockfile — usepip-compileoruv lock- Ranges in the declaration, exact pins in the lockfile; commit the lockfile
pyproject.tomlreplaces six other configuration files--generate-hashesfor auditable environments- A lockfile plus a pinned container image is what full reproducibility requires
src/layout so tests exercise the installed package
Next: Git and deployment.