This guide picks up where Lab computing setup leaves off. It assumes you already have a terminal, Git, GitHub, and an editor working.
What follows is how the lab starts and runs a Python project, so that a year from now someone else — very possibly you — can clone it and reproduce what you did.
Why uv
Python’s oldest source of pain is that installing packages and managing interpreters are two
separate problems solved by a dozen half-overlapping tools. uv collapses all of it into one
program: it installs Python versions, creates virtual environments, resolves and locks
dependencies, and runs your code. It is also fast enough that you stop thinking about it.
If you have used python -m venv, pip, pip-tools, pyenv, or conda, uv replaces all of
them for a project like ours. There is a section at the end on working with older projects that
still use pip.
Install uv and a Python
Windows
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"macOS
curl -LsSf https://astral.sh/uv/install.sh | shLinux
curl -LsSf https://astral.sh/uv/install.sh | shClose and reopen your terminal, then:
uv --version
Now install a Python. uv manages these itself — you do not need one from python.org:
uv python install 3.13
Start a project
uv init assay-analysis
cd assay-analysis
That gives you:
| File | What it is |
|---|---|
pyproject.toml |
The project’s definition — its name, its Python version, its dependencies. The one file that matters most. |
.python-version |
Which Python this project uses, so everyone gets the same one. |
README.md |
Explain what the project does. Actually write this one. |
.gitignore |
Files Git should never track. |
main.py |
A placeholder entry point. |
Two files you will see shortly but that do not exist yet: .venv/ (the project’s private
Python environment) and uv.lock (the exact resolved version of every dependency). Both are
created the first time you run uv add, uv sync, or uv run — not by uv init.
Add dependencies
uv add numpy pandas matplotlib
This resolves the versions, writes them into pyproject.toml, records the exact resolution in
uv.lock, and installs everything into .venv/. There is no separate “create the environment”
or “activate the environment” step — uv handles it.
Development-only tools go in a separate group so they do not ship with the project:
uv add --dev pytest ruff
To remove something, uv remove pandas. To install exactly what the lockfile says — which is
what you do after cloning someone else’s project — uv sync.
Project layout
For anything beyond a single script, put your code in src/:
assay-analysis/
├── .venv/ ← not in Git
├── .gitignore
├── pyproject.toml
├── uv.lock ← in Git
├── README.md
├── data/
│ └── raw/ ← not in Git (see "Data" below)
├── notebooks/
│ └── 01-explore.ipynb
├── src/
│ └── assay_analysis/
│ ├── __init__.py
│ ├── io.py ← reading plate-reader exports
│ ├── normalize.py ← blank subtraction, plate normalization
│ └── fitting.py ← dose–response curve fitting
└── tests/
└── test_normalize.py
The reason for src/ rather than putting the package at the top level is that it forces your
code to be installed to be importable, which means your tests exercise the package the same
way a user would. It catches a whole class of “works on my machine” bug where a module only
imports because you happened to be standing in the right directory.
Tell uv the package lives there by adding this to pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/assay_analysis"]
Keep modules small and named after what they do. normalize.py holding the normalization logic
is worth ten times a utils.py holding everything.
Running your code
Prefix commands with uv run and the project environment is used automatically:
uv run python -m assay_analysis
uv run python scripts/fit_plate.py
You never have to remember whether you activated an environment, because you never activate
one. If a command works with uv run in front of it, it is using the right Python and the
right packages.
Give the things you run often a name in pyproject.toml:
[project.scripts]
fit-plate = "assay_analysis.fitting:main"
after which uv run fit-plate works from anywhere in the project.
Ruff
Ruff is both the linter (finds problems) and the formatter (fixes layout). One tool, and it is fast enough to run on every save.
uv run ruff format . # reformat everything
uv run ruff check . # report problems
uv run ruff check --fix . # fix the ones it can fix automatically
Configure it in pyproject.toml so everyone working on the project gets identical behavior:
[tool.ruff]
line-length = 88
[tool.ruff.lint]
# pycodestyle errors, pyflakes, isort, and common bug patterns.
select = ["E", "F", "I", "B"]
You already turned on format-on-save in the setup guide. With the config above in the repo, the editor and the command line agree, and code review never turns into an argument about whitespace.
Tests
You do not need a comprehensive test suite. You need enough that when you change your normalization code six months from now, you find out immediately if you broke it.
# tests/test_normalize.py
import numpy as np
from assay_analysis.normalize import subtract_blank
def test_subtract_blank_removes_the_blank_mean():
plate = np.array([[1.0, 2.0], [3.0, 4.0]])
blanks = np.array([1.0, 1.0])
result = subtract_blank(plate, blanks)
assert np.allclose(result, [[0.0, 1.0], [2.0, 3.0]])
def test_subtract_blank_rejects_mismatched_shapes():
import pytest
with pytest.raises(ValueError):
subtract_blank(np.zeros((2, 2)), np.zeros((3,)))
Run them with:
uv run pytest
Two tests like these — one for the expected case, one for the failure you are worried about — already pay for themselves. Write them for the code that does the actual science: the normalization, the curve fitting, the unit conversions. Those are the places where a silent bug becomes a wrong figure in a paper.
Notebooks
Notebooks are excellent for exploring and poor for anything you need to trust twice. The lab’s
rule of thumb: explore in a notebook, then move anything you rely on into src/ where it can be
tested and imported.
uv add --dev jupyterlab
uv run jupyter lab
Number your notebooks in the order they should be read (01-explore.ipynb,
02-fit-curves.ipynb). Import your own code into them rather than pasting functions in:
from assay_analysis.normalize import subtract_blank
Data
Git is built for text, not for gigabytes of instrument output. Committing raw data makes the repository permanently large — Git keeps everything forever, so deleting the file later does not shrink it.
In the repository: your code, your pyproject.toml and uv.lock, small reference files
(plate maps, sample manifests, configuration), and the scripts that produce your figures.
Not in the repository: raw instrument exports, large intermediate arrays, model checkpoints, anything a script can regenerate. Keep these in the lab’s S3 bucket — see the Cloud computing on AWS — and note in your README exactly where they live.
Add this to .gitignore at the start, before you accidentally commit something big:
.venv/
__pycache__/
*.pyc
.ipynb_checkpoints/
data/raw/
data/interim/
*.h5
*.ckpt
Working with Claude Code
Start it from inside the project directory so it can see your code:
cd assay-analysis
claude
Write a CLAUDE.md at the top of the repository. It is loaded automatically at the start of
every session, and it is the difference between an assistant that guesses at your conventions
and one that follows them:
# CLAUDE.md
Analysis of plate-reader assay data for the polymer screening project.
## Commands
- `uv run pytest` — run tests
- `uv run ruff check --fix .` — lint and autofix
- `uv run fit-plate` — fit dose–response curves for a plate
## Conventions
- Dependencies are managed with uv. Never edit uv.lock by hand.
- Analysis code lives in src/assay_analysis/. Notebooks import from it; they
do not define functions.
- Raw data is not in this repo — it is in S3 (see README).
Two habits worth forming early. Read the diffs before you accept them — it is your name on the commit and your figure in the paper. And when something matters scientifically, ask for a test alongside the change, so the behavior is pinned down rather than just asserted.
Day-to-day Git
The loop, in the order you will actually use it.
Starting work — always pull first, so you are building on what is current:
git pull
Starting something new — branch, so main stays working:
git switch -c normalize-by-plate
Saving your work — stage, then commit with a message that says why, not what (the diff already shows what):
git add .
git commit -m "Normalize per plate rather than per experiment
Plate-to-plate variation was large enough that the pooled blank
was biasing low-signal wells."
Publishing it — the first push on a new branch needs -u to link it to GitHub; after that
git push alone is enough:
git push -u origin normalize-by-plate
Getting it reviewed — open a pull request from the terminal:
gh pr create --fill
Commit often and in small pieces. A commit that changes one thing is one you can understand, review, and undo. A commit called “updates” containing three days of work is none of those.
Appendix: projects that still use pip
You will meet repositories with a requirements.txt and no pyproject.toml — older lab code,
a collaborator’s repo, a paper’s supplementary code. They still work:
python -m venv .venv
Windows
.venv\Scripts\Activate.ps1macOS
source .venv/bin/activateLinux
source .venv/bin/activatepip install -r requirements.txt
Your prompt changes to show (.venv) when the environment is active. deactivate leaves it.
uv can also read these directly without you activating anything, which is usually less trouble:
uv venv
uv pip install -r requirements.txt
uv run python your_script.py
If the project is one the lab will keep maintaining, it is worth converting it properly: run
uv init in the directory, uv add each dependency from the requirements file, commit the
resulting pyproject.toml and uv.lock, and delete requirements.txt.