Git and deployment
Lesson 16 — Python
Learning objectives
- Apply Git effectively to a Python project
- Enforce quality automatically with pre-commit hooks
- Package a project for distribution
- Containerise and deploy an application
- Build a CI/CD pipeline
- Structure logging and configuration for production
Git for Python projects
The Git fundamentals are language-agnostic — see R Programming lesson 12 for commits, branches, pull requests and recovery. This lesson covers what is specific to Python.
.gitignore
# Environments
.venv/
venv/
env/
.conda/
# Byte-compiled
__pycache__/
*.py[cod]
*$py.class
*.so
# Packaging
build/
dist/
*.egg-info/
.eggs/
# Tooling caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
.tox/
.coverage
coverage.xml
htmlcov/
# Notebooks
.ipynb_checkpoints/
# Editors
.idea/
.vscode/
*.swp
.DS_Store
# Secrets
.env
.env.local
*.pem
credentials.json
# Data — never commit patient data
data/raw/
data/derived/
*.sas7bdat
*.xpt
*.parquet
output/
Start from github/gitignore’s Python template and add the data and secrets sections.
.ipynb files store output, execution counts and metadata as JSON. Every run produces a diff even when the code did not change, and cell output can contain patient data.
pip install nbstripout
nbstripout --install # a git filter that strips output on commitOr use jupytext to pair each notebook with a .py file and commit only that:
jupytext --set-formats ipynb,py:percent notebook.ipynbCommitting raw notebooks with output is how patient data ends up in a repository.
pre-commit
Runs checks before each commit, so problems never reach the repository.
pip install pre-commit
pre-commit install# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=1000"]
- id: check-merge-conflict
- id: detect-private-key
- id: no-commit-to-branch
args: ["--branch", "main"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.10
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [pandas-stubs, types-requests]
- repo: https://github.com/kynan/nbstripout
rev: 0.7.1
hooks:
- id: nbstripoutpre-commit run --all-files # run against everything, first time
pre-commit autoupdate # bump the hook versions
git commit --no-verify # bypass — use sparinglydetect-private-key and check-added-large-files are the two that earn their place immediately: they prevent the two mistakes that are painful to undo.
Linting and formatting
ruff replaces flake8, isort, pyupgrade, pydocstyle and several others, and is fast enough to run on save.
pip install ruff
ruff check . # lint
ruff check --fix . # autofix
ruff format . # format (replaces black)
ruff format --check . # check without changing[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear — catches the mutable-default trap
"SIM", # flake8-simplify
"PD", # pandas-vet
"RUF", # ruff-specific
]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # assert is fine in testsB (bugbear) is the ruleset worth enabling above all others — it catches mutable default arguments, loop variable binding in closures, and several other real bugs rather than style issues.
Packaging
# pyproject.toml
[project]
name = "study-abc101"
version = "1.2.0"
description = "Analysis programs for study ABC-101"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
dependencies = ["pandas>=2.0", "pyreadstat>=1.2"]
[project.scripts]
run-adam = "study.pipeline:main"
validate = "study.validation:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/study"]pip install build twine
python -m build # builds sdist and wheel into dist/
twine check dist/*
twine upload --repository testpypi dist/*
twine upload dist/* # PyPI
# Internal index
pip install --index-url https://pypi.company.com/simple/ study-abc101[project.scripts] creates command-line entry points:
# src/study/pipeline.py
import argparse
import sys
def main() -> int:
parser = argparse.ArgumentParser(description="Run the ADaM pipeline")
parser.add_argument("--config", required=True, help="Path to the config file")
parser.add_argument("--stage", choices=["adam", "tlf", "all"], default="all")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
try:
run_pipeline(args.config, args.stage, dry_run=args.dry_run)
except Exception:
logging.exception("Pipeline failed")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())run-adam --config config/study.yaml --stage adamReturning an exit code — 0 for success, non-zero for failure — is what lets a scheduler or CI system detect that the run failed.
Configuration
# src/study/config.py
from dataclasses import dataclass
from pathlib import Path
import os
import yaml
@dataclass(frozen=True)
class Config:
study_id: str
sdtm_path: Path
adam_path: Path
output_path: Path
log_level: str = "INFO"
n_workers: int = 4
@classmethod
def from_yaml(cls, path: str | Path) -> "Config":
raw = yaml.safe_load(Path(path).read_text())
# Environment variables override the file
env = os.getenv("STUDY_ENV", "development")
merged = {**raw.get("default", {}), **raw.get(env, {})}
return cls(
study_id=merged["study_id"],
sdtm_path=Path(merged["sdtm_path"]),
adam_path=Path(merged["adam_path"]),
output_path=Path(merged["output_path"]),
log_level=os.getenv("LOG_LEVEL", merged.get("log_level", "INFO")),
n_workers=int(os.getenv("N_WORKERS", merged.get("n_workers", 4))),
)# config/study.yaml
default:
study_id: ABC-101
sdtm_path: data/raw/sdtm
adam_path: data/adam
output_path: output
log_level: INFO
production:
sdtm_path: /mnt/production/abc101/sdtm
adam_path: /mnt/production/abc101/adam
output_path: /mnt/production/abc101/output
log_level: WARNING
n_workers: 16Secrets never go in the YAML:
db_password = os.environ["DB_PASSWORD"] # KeyError if unset — fail loudly
api_key = os.getenv("API_KEY") # None if unsetyaml.safe_load, never yaml.load — the latter can execute arbitrary Python from the file.
Logging
# src/study/logging_config.py
import logging
import logging.config
from pathlib import Path
def configure_logging(level: str = "INFO", log_file: Path | None = None) -> None:
handlers: dict = {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
"level": level,
}
}
if log_file:
log_file.parent.mkdir(parents=True, exist_ok=True)
handlers["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(log_file),
"maxBytes": 10_000_000,
"backupCount": 5,
"formatter": "detailed",
"level": "DEBUG",
}
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {"format": "%(levelname)-8s %(message)s"},
"detailed": {"format":
"%(asctime)s %(levelname)-8s [%(name)s:%(lineno)d] %(message)s"},
},
"handlers": handlers,
"root": {"level": "DEBUG", "handlers": list(handlers)},
})import logging
logger = logging.getLogger(__name__) # module-level, named after the module
def derive_adsl(dm, ex):
logger.info("Deriving ADSL from %d DM and %d EX records", len(dm), len(ex))
try:
adsl = _derive(dm, ex)
except Exception:
logger.exception("ADSL derivation failed") # includes the traceback
raise
logger.info("ADSL: %d subjects, %d variables", len(adsl), len(adsl.columns))
return adslUse %s placeholders rather than f-strings in log calls: the formatting is deferred and skipped entirely if the level is disabled. logger.exception() inside an except block includes the traceback automatically.
Docker
# syntax=docker/dockerfile:1
FROM python:3.11.9-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.4.20 /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY src/ ./src/
RUN uv sync --frozen --no-dev
FROM python:3.11.9-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appuser /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER appuser
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD curl -fsS http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["python", "-m", "study.pipeline"]The multi-stage build keeps build tools out of the runtime image, which is smaller and has a smaller attack surface.
# docker-compose.yml
services:
app:
build: .
image: study-abc101:1.2.0
ports: ["127.0.0.1:8000:8000"]
environment:
DB_PASSWORD: ${DB_PASSWORD:?must be set}
STUDY_ENV: production
LOG_LEVEL: INFO
volumes:
- type: bind
source: /mnt/studies/abc101
target: /data
read_only: true
- ./logs:/app/logs
restart: unless-stopped
deploy:
resources:
limits: {memory: 4G, cpus: "2.0"}CI/CD
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
env:
PYTHON_VERSION: "3.11"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync --frozen --extra dev
- run: uv run ruff check --output-format=github .
- run: uv run ruff format --check .
- run: uv run mypy src/
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv python install ${{ matrix.python-version }}
- run: uv sync --frozen --extra dev
- run: uv run pytest --cov=study --cov-report=xml
- uses: codecov/codecov-action@v4
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
build:
needs: [quality, test]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=sha
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxBuilding and pushing only on a tag, and only after quality and tests pass, means every deployed image corresponds to a named, reviewable version.
Deployment targets
| Target | Suits | Notes |
|---|---|---|
| Docker on a VM | Full control | You manage everything |
| Kubernetes | Scale, multiple services | Substantial operational overhead |
| AWS ECS / Fargate | Containers without cluster management | |
| Azure Container Apps | Same, Azure | |
| Google Cloud Run | Scale to zero, per-request billing | Good for intermittent workloads |
| Streamlit Community Cloud | Streamlit apps | Free, public |
| Posit Connect | Shiny for Python, Quarto, APIs | Enterprise, authentication included |
| Databricks / Snowflake | Data-platform-native | Where the data already is |
| Airflow / Prefect / Dagster | Scheduled pipelines | Orchestration, retries, monitoring |
For a scheduled analysis pipeline, an orchestrator is usually the right answer rather than a cron job:
# Prefect
from prefect import flow, task
@task(retries=3, retry_delay_seconds=60)
def read_sdtm(domain: str):
...
@task
def derive_adsl(dm, ex):
...
@flow(name="ABC-101 ADaM pipeline", log_prints=True)
def adam_pipeline():
dm = read_sdtm("dm")
ex = read_sdtm("ex")
adsl = derive_adsl(dm, ex)
return adslR and Python side by side
| Task | R | Python |
|---|---|---|
| Project config | DESCRIPTION |
pyproject.toml |
| Lockfile | renv.lock |
uv.lock |
| Build | R CMD build |
python -m build |
| Install | install.packages() |
pip install |
| Lint | lintr |
ruff check |
| Format | styler |
ruff format |
| Type check | none standard | mypy |
| Test | testthat |
pytest |
| Pre-commit | precommit package |
pre-commit |
| CI | r-lib/actions |
setup-python / setup-uv |
| Container | rocker/* |
python:*-slim |
| Publish | CRAN | PyPI |
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Committing notebooks with output | Diff noise; possible patient data | nbstripout |
| Secrets in the repository | Leaked permanently | .env, detect-private-key |
| No pre-commit hooks | Problems reach the repository | Install them |
latest base image |
Non-reproducible builds | Pin the exact tag |
| Root user in the container | Larger blast radius | USER appuser |
| f-strings in log calls | Formatting cost even when disabled | %s placeholders |
yaml.load |
Arbitrary code execution | yaml.safe_load |
| Deploying without a tag | Cannot say what is running | Tag, and deploy on tags |
| No exit code from the CLI | Failures look like successes | sys.exit(main()) |
Exercise 11.1 — Production-ready project setup
Set up a complete Python project with pre-commit hooks, CI, a container build, and a CLI entry point. Explain each choice.
Show solution
Structure
study-abc101/
├── .github/workflows/ci.yml
├── .pre-commit-config.yaml
├── .gitignore
├── .python-version
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── README.md
├── pyproject.toml
├── uv.lock
├── config/
│ └── study.yaml
├── src/study/
│ ├── __init__.py
│ ├── cli.py
│ ├── config.py
│ ├── logging_config.py
│ ├── derivations.py
│ └── pipeline.py
└── tests/
├── conftest.py
└── test_derivations.py
src/study/cli.py
"""Command-line interface for the ABC-101 analysis pipeline."""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
from study import __version__
from study.config import Config
from study.logging_config import configure_logging
from study.pipeline import run_pipeline
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="run-adam",
description="Run the ABC-101 ADaM and TLF pipeline.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--config", type=Path, default=Path("config/study.yaml"),
help="Path to the configuration file")
p.add_argument("--stage", choices=["adam", "tlf", "all"], default="all",
help="Which stage to run")
p.add_argument("--log-file", type=Path, default=None,
help="Write a detailed log to this file")
p.add_argument("--log-level", default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"])
p.add_argument("--dry-run", action="store_true",
help="Validate inputs and report the plan without writing output")
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
return p
def main(argv: list[str] | None = None) -> int:
"""Entry point. Returns a process exit code."""
args = build_parser().parse_args(argv)
configure_logging(level=args.log_level, log_file=args.log_file)
logger.info("study-abc101 %s starting (stage=%s, dry_run=%s)",
__version__, args.stage, args.dry_run)
try:
config = Config.from_yaml(args.config)
except FileNotFoundError:
logger.error("Configuration file not found: %s", args.config)
return 2
except (KeyError, ValueError) as e:
logger.error("Invalid configuration: %s", e)
return 2
try:
summary = run_pipeline(config, stage=args.stage, dry_run=args.dry_run)
except KeyboardInterrupt:
logger.warning("Interrupted by user")
return 130
except Exception:
logger.exception("Pipeline failed")
return 1
n_failed = sum(1 for s in summary if s["status"] == "FAILED")
if n_failed:
logger.error("%d of %d step(s) failed", n_failed, len(summary))
return 1
logger.info("Completed %d step(s) successfully", len(summary))
return 0
if __name__ == "__main__":
sys.exit(main())Why each choice
Distinct exit codes. 0 success, 1 pipeline failure, 2 configuration error, 130 interrupted. A scheduler can distinguish “the data was bad” from “the config was wrong” and alert differently. A single 1 for everything loses that.
main(argv=None) taking an argument list. This makes the CLI testable without subprocesses:
def test_missing_config_returns_2(tmp_path):
assert main(["--config", str(tmp_path / "nope.yaml")]) == 2configure_logging before anything else. A configuration error must be logged, so logging has to be up first.
logger.exception rather than logger.error in the catch-all — it includes the traceback, which is the whole reason you are looking at the log.
--dry-run. Validating inputs and printing the plan without writing anything is the difference between discovering a path problem in ten seconds and discovering it after two hours of derivation.
Explicit KeyboardInterrupt handling. Ctrl-C should log a clean message and return 130 (the Unix convention), not print a traceback.
Makefile
.PHONY: help install test lint format check run docker clean
help:
@grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
install: ## Install the project and dev dependencies
uv sync --frozen --extra dev
uv run pre-commit install
test: ## Run the test suite
uv run pytest
lint: ## Lint and type-check
uv run ruff check .
uv run ruff format --check .
uv run mypy src/
format: ## Autoformat and autofix
uv run ruff format .
uv run ruff check --fix .
check: lint test ## Everything CI runs
run: ## Run the pipeline locally
uv run run-adam --config config/study.yaml --log-file logs/run.log
docker: ## Build the container
docker build -t study-abc101:$$(grep '^version' pyproject.toml | cut -d'"' -f2) .
clean:
rm -rf .pytest_cache .mypy_cache .ruff_cache htmlcov .coverage dist build
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || trueThe help target with ## comments means make alone documents the project. A new team member runs make install and make check and is productive without reading anything.
What this setup actually prevents
| Control | Prevents |
|---|---|
pre-commit + detect-private-key |
Credentials in the repository |
pre-commit + nbstripout |
Notebook output (possibly patient data) in Git |
pre-commit + check-added-large-files |
A 2 GB dataset committed by accident |
ruff rule B |
Mutable default arguments and closure bugs |
mypy |
Type errors that would surface at runtime |
uv.lock + --frozen |
Different package versions between machines |
| Pinned base image | A rebuild producing a different Python |
| Non-root container user | Container escape having root |
| Tag-gated Docker push | Deploying an unreviewed commit |
| Distinct exit codes | A scheduler reporting success on failure |
Recap
- Strip notebook output before committing — it is diff noise and a data risk
pre-commitwithdetect-private-keyandcheck-added-large-filesprevents the expensive mistakesruffreplaces five tools; enable theBruleset for real bugspyproject.toml+[project.scripts]gives a proper CLI entry point- Multi-stage Docker builds, pinned base tags, non-root user
%splaceholders in log calls;logger.exception()insideexcept- Build and deploy on a tag, after tests pass
- Distinct exit codes so a scheduler can tell what went wrong
That completes the core Python track. You can read and write idiomatic Python, wrangle data with pandas, run statistical analyses, read and build clinical datasets, ship an application, and deploy it reproducibly.
The remaining four lessons cover generative AI — a distinct subject that builds on everything above, particularly virtual environments for dependency management and testing for the non-determinism that LLMs introduce.