Skip to main content

Project 02 - CI Quality Pipeline Setup

Estimated time: 3–5 hours | Level: Intermediate

Before reading the requirements, answer this: your CI pipeline runs tests on Python 3.11. A user reports a bug that only occurs on Python 3.10. Your tests passed. Why?

The answer is that testing against a single Python version is not testing - it is spot-checking. This project sets up the pipeline that catches the bug before the user does.

Learning Objectives

By completing this project you will have practiced:

  • Writing a complete .pre-commit-config.yaml for a production Python project
  • Writing a complete pyproject.toml that configures all development tools from one file
  • Designing a GitLab CI pipeline with proper stage ordering, artifact passing, and caching
  • Configuring a coverage gate that fails CI if branch coverage falls below a threshold
  • Setting up a test matrix across Python 3.10, 3.11, and 3.12 using nox
  • Writing a Makefile that makes common operations discoverable and repeatable

The Project Structure

You are setting up the quality pipeline for a Python project with this layout:

myproject/
src/
myproject/
__init__.py
core.py
utils.py
tests/
conftest.py
unit/
test_core.py
test_utils.py
integration/
test_integration.py
.pre-commit-config.yaml
pyproject.toml
noxfile.py
Makefile
.gitlab-ci.yml
.secrets.baseline
README.md

All configuration files produced in this project apply to this structure. The src/ layout is deliberate: it prevents the package from being accidentally importable from the project root without installing it, which catches missing __init__.py errors that flat layouts hide.

Requirements

R1 - .pre-commit-config.yaml

Produce a complete pre-commit configuration with:

  • pre-commit-hooks: trailing-whitespace, end-of-file-fixer, check-yaml, check-json, check-toml, check-merge-conflict, check-added-large-files, detect-private-key, no-commit-to-branch
  • detect-secrets: with a baseline file
  • ruff: check with --fix and --exit-non-zero-on-fix
  • ruff-format: Black-compatible formatting
  • mypy: type checking on commit stage, Python files only
  • pytest fast tests: not slow marker, commit stage
  • pytest full tests: all tests, push stage

All hooks must be pinned to specific version tags.

R2 - pyproject.toml with full tool configuration

Produce a complete pyproject.toml covering:

  • Project metadata: name, version, authors, description, Python version requirement
  • Dependencies and optional dev dependencies
  • [tool.ruff]: target version, line length, rule selection (E, W, F, I, N, UP, B, S, RET, SIM), ignores, per-file ignores for tests
  • [tool.black]: line length, target versions
  • [tool.mypy]: python version, strict settings appropriate for a library, per-module overrides for untyped third-party libraries
  • [tool.pytest.ini_options]: addopts with coverage settings, testpaths, markers
  • [tool.coverage.run]: source, branch = true, omit list
  • [tool.coverage.report]: fail_under = 85, show_missing = true, skip_covered = false

R3 - .gitlab-ci.yml with four stages

Pipeline stages: linttestcoveragebuild

  • lint stage: run pre-commit run --all-files; fail fast if any hook fails; cache pre-commit environments
  • test stage: run pytest with JUnit XML output for GitLab test report artifacts; run in parallel across Python 3.10, 3.11, 3.12 using a job matrix; cache .tox or .nox directories
  • coverage stage: run pytest with coverage report; fail if below 85%; upload coverage HTML as artifact; generate coverage badge data
  • build stage: run python -m build; upload wheel and sdist as artifacts; only run on main branch or version tags

R4 - Coverage gate at 85% branch coverage

The coverage gate must appear in two places:

  1. [tool.coverage.report] fail_under = 85 in pyproject.toml - enforced locally
  2. A dedicated CI job that fails the pipeline if coverage drops below 85% - enforced remotely

R5 - JUnit XML test reports

Configure pytest to output JUnit XML so GitLab can display test results in the merge request UI:

pytest --junitxml=reports/junit.xml

The .gitlab-ci.yml test job must upload this file as a GitLab test artifact.

R6 - Coverage badge

The coverage job must produce a badge JSON file that GitLab Badges can read:

{
"schemaVersion": 1,
"label": "coverage",
"message": "87%",
"color": "brightgreen"
}

Store it in public/coverage-badge.json and upload as a Pages artifact so it can be linked from the README.

R7 - noxfile.py for testing across Python 3.10, 3.11, 3.12

Use nox (preferred over tox for its Python-native configuration) to define test sessions:

  • tests session: run for each Python version, install dev dependencies, run pytest
  • lint session: run ruff and mypy
  • coverage session: run pytest with coverage and fail under threshold

R8 - Makefile with standard targets

Produce a Makefile with targets: make lint, make test, make coverage, make build, make clean, make all, and a make help that lists all targets with descriptions.

Complete File Contents

.pre-commit-config.yaml

minimum_pre_commit_version: "3.5.0"

default_stages: [commit]

repos:
# ── File hygiene ──────────────────────────────────────────────────────────────
- 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
args: [--unsafe]
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files
args: [--maxkb=500]
- id: detect-private-key
- id: no-commit-to-branch
args: [--branch, main, --branch, master]

# ── Secret detection ──────────────────────────────────────────────────────────
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: [--baseline, .secrets.baseline]
exclude: .secrets.baseline

# ── Python: Ruff lint + format ────────────────────────────────────────────────
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.5
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
types: [python]
- id: ruff-format
types: [python]

# ── Python: mypy type checking (commit stage) ────────────────────────────────
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
args:
- --ignore-missing-imports
- --no-error-summary
- --config-file=pyproject.toml
types: [python]
additional_dependencies:
- types-requests
- types-PyYAML

# ── Python: fast tests (commit stage) ────────────────────────────────────────
- repo: local
hooks:
- id: pytest-fast
name: pytest (fast tests only)
language: system
entry: pytest tests/ -m "not slow" --tb=short -q --no-header
pass_filenames: false
types: [python]
stages: [commit]

# ── Python: full tests (push stage) ──────────────────────────────────────────
- repo: local
hooks:
- id: pytest-full
name: pytest (full suite)
language: system
entry: pytest tests/ --tb=short -q --no-header
pass_filenames: false
stages: [push]

Reasoning for each choice:

  • check-yaml --unsafe: required for GitLab CI YAML which uses !reference tags (not valid strict YAML)
  • detect-secrets with baseline: distinguishes known false positives from new secrets; the baseline is version-controlled so the whole team shares the same allowlist
  • ruff --exit-non-zero-on-fix: forces the developer to re-stage auto-fixed files rather than silently committing the pre-fix version
  • mypy --no-error-summary: suppresses the "Found N errors" summary line which adds noise without information
  • Fast tests on commit, full tests on push: commit should take < 3 seconds; push can take up to 30 seconds

pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

# ── Project metadata ──────────────────────────────────────────────────────────

[project]
name = "myproject"
version = "0.1.0"
description = "A well-configured Python project"
readme = "README.md"
license = { text = "MIT" }
authors = [
{ name = "Your Name", email = "[email protected]" },
]
requires-python = ">=3.10"
dependencies = [
"requests>=2.31.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-cov>=5.0.0",
"hypothesis>=6.100.0",
"mypy>=1.10.0",
"ruff>=0.4.5",
"pre-commit>=3.7.0",
"nox>=2024.3.2",
"build>=1.2.0",
"types-requests",
"types-PyYAML",
]

[project.urls]
Homepage = "https://gitlab.com/yourorg/myproject"
Repository = "https://gitlab.com/yourorg/myproject"
Issues = "https://gitlab.com/yourorg/myproject/-/issues"

# ── Ruff: linting and formatting ─────────────────────────────────────────────

[tool.ruff]
target-version = "py310"
line-length = 88

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes: undefined names, unused imports
"I", # isort: import order
"N", # pep8-naming
"UP", # pyupgrade: modern Python syntax
"B", # flake8-bugbear: likely bugs
"S", # flake8-bandit: security issues
"RET", # flake8-return: return statement hygiene
"SIM", # flake8-simplify: simplifiable code
"C90", # mccabe: cyclomatic complexity
]
ignore = [
"E501", # line too long - handled by ruff format / Black
"S101", # use of assert - acceptable in test files (see per-file-ignores)
"S603", # subprocess without shell=True - we control subprocess calls
]
fixable = ["E", "W", "F", "I", "UP", "RET", "SIM"]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S", # no security checks in test files
"ANN", # no annotation requirements in tests
"D", # no docstring requirements in tests
]
"noxfile.py" = ["ANN", "D"]
"scripts/**/*.py" = ["T201"] # allow print() in scripts

[tool.ruff.lint.mccabe]
# Fail functions with cyclomatic complexity > 10
max-complexity = 10

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false

# ── Black: formatter ─────────────────────────────────────────────────────────

[tool.black]
line-length = 88
target-version = ["py310", "py311", "py312"]
extend-exclude = '''
/(
| \.venv
| \.nox
| \.tox
| dist
| build
| migrations
)/
'''

# ── mypy: type checking ──────────────────────────────────────────────────────

[tool.mypy]
python_version = "3.11"
files = ["src"]

# Core correctness rules
warn_return_any = true
warn_unused_configs = true
warn_unused_ignores = true
no_implicit_optional = true
strict_equality = true

# Enable for library code where type safety is critical
disallow_untyped_defs = true
disallow_any_generics = true
check_untyped_defs = true

# Prevent implicit re-exports from __init__.py
no_implicit_reexport = true

# Report missing type information from third-party libraries
warn_no_return = true

[[tool.mypy.overrides]]
# Third-party libraries without complete type stubs
module = [
"some_untyped_lib.*",
]
ignore_missing_imports = true

# ── pytest: test runner ──────────────────────────────────────────────────────

[tool.pytest.ini_options]
addopts = [
# Coverage settings
"--cov=myproject",
"--cov-report=term-missing:skip-covered",
"--cov-report=html:reports/htmlcov",
"--cov-report=xml:reports/coverage.xml",
"--cov-branch",
# JUnit XML for CI test reporting
"--junitxml=reports/junit.xml",
# Output settings
"--tb=short",
"--strict-markers", # fail if unknown markers are used
"--strict-config", # fail if config has warnings
"-ra", # show summary of all non-passing tests
]
testpaths = ["tests"]
markers = [
"slow: marks tests as slow (deselect with '-m not slow')",
"integration: marks tests as integration tests",
"property: marks tests as property-based tests",
"performance: marks tests as performance tests",
]
# Minimum pytest version (enforces team consistency)
minversion = "8.0"

# ── Coverage configuration ───────────────────────────────────────────────────

[tool.coverage.run]
source = ["myproject"]
branch = true
omit = [
"*/tests/*",
"*/__main__.py",
"*/migrations/*",
]

[tool.coverage.report]
# Fail CI if branch coverage drops below 85%
fail_under = 85
show_missing = true
skip_covered = false
exclude_lines = [
# Standard exclusions - these lines cannot be practically tested
"pragma: no cover",
"def __repr__",
"def __str__",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@(abc\\.)?abstractmethod",
]

[tool.coverage.html]
directory = "reports/htmlcov"

[tool.coverage.xml]
output = "reports/coverage.xml"

Reasoning for key choices:

  • hatchling as build backend: modern, fast, zero-config for src/ layout. Alternative: setuptools (more control), flit (simpler but fewer features).
  • requires-python = ">=3.10": 3.10 minimum because that is when match statements, X | Y union syntax, and parenthesized context managers became available. Match your actual minimum.
  • disallow_untyped_defs = true in mypy: appropriate for a library. For an application, you might start with this disabled and enable per-module.
  • --strict-markers in pytest: prevents typos in marker names from silently being accepted. pytest -m "ntoo slow" would not silently select all tests - it would fail immediately with "unknown marker".
  • fail_under = 85 in coverage.report: enforced locally and in CI. 85% branch coverage is a reasonable floor; 90%+ is achievable for well-structured code.

.gitlab-ci.yml

# ── Pipeline configuration ────────────────────────────────────────────────────

stages:
- lint
- test
- coverage
- build

variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
PRE_COMMIT_HOME: "$CI_PROJECT_DIR/.pre-commit-cache"
PYTHON_VERSION: "3.11" # default Python version for single-version jobs

# ── Cache templates (reusable via YAML anchors) ───────────────────────────────

.pip_cache: &pip_cache
cache:
key: pip-${CI_COMMIT_REF_SLUG}
paths:
- .pip-cache/
policy: pull-push

.pre_commit_cache: &pre_commit_cache
cache:
key: pre-commit-${CI_COMMIT_REF_SLUG}
paths:
- .pre-commit-cache/
policy: pull-push

.python_base: &python_base
image: python:${PYTHON_VERSION}-slim
before_script:
- pip install --upgrade pip --quiet
- pip install -e ".[dev]" --quiet

# ── Stage: lint ───────────────────────────────────────────────────────────────

lint:pre-commit:
stage: lint
image: python:3.11-slim
<<: *pre_commit_cache
before_script:
- pip install pre-commit --quiet
script:
- pre-commit run --all-files --show-diff-on-failure
rules:
# Run on merge requests and on main branch pushes
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG

lint:mypy:
stage: lint
<<: *python_base
<<: *pip_cache
script:
- mypy src/ --config-file pyproject.toml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"

# ── Stage: test ───────────────────────────────────────────────────────────────

# Test matrix: run tests on Python 3.10, 3.11, 3.12
.test_template: &test_template
stage: test
<<: *pip_cache
script:
- pytest tests/
--junitxml=reports/junit-${PYTHON_VERSION}.xml
--tb=short
-q
--no-header
-m "not slow"
artifacts:
when: always # upload even on failure so we can see what failed
reports:
junit: reports/junit-${PYTHON_VERSION}.xml
paths:
- reports/junit-${PYTHON_VERSION}.xml
expire_in: 1 week

test:python310:
<<: *test_template
image: python:3.10-slim
variables:
PYTHON_VERSION: "3.10"
before_script:
- pip install --upgrade pip --quiet
- pip install -e ".[dev]" --quiet

test:python311:
<<: *test_template
image: python:3.11-slim
variables:
PYTHON_VERSION: "3.11"
before_script:
- pip install --upgrade pip --quiet
- pip install -e ".[dev]" --quiet

test:python312:
<<: *test_template
image: python:3.12-slim
variables:
PYTHON_VERSION: "3.12"
before_script:
- pip install --upgrade pip --quiet
- pip install -e ".[dev]" --quiet

# ── Stage: coverage ───────────────────────────────────────────────────────────

coverage:report:
stage: coverage
<<: *python_base
<<: *pip_cache
script:
# Run the full test suite with coverage (including slow tests)
- pytest tests/
--cov=myproject
--cov-report=term-missing
--cov-report=html:reports/htmlcov
--cov-report=xml:reports/coverage.xml
--cov-branch
--cov-fail-under=85
--junitxml=reports/junit-coverage.xml
# Generate coverage badge JSON for README
- python scripts/generate_coverage_badge.py reports/coverage.xml public/coverage-badge.json
coverage: '/TOTAL.*\s+(\d+\%)$/' # GitLab regex to extract coverage % from output
artifacts:
when: always
reports:
junit: reports/junit-coverage.xml
coverage_report:
coverage_format: cobertura
path: reports/coverage.xml
paths:
- reports/htmlcov/
- reports/coverage.xml
- public/coverage-badge.json
expire_in: 1 month
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"

# ── Stage: build ──────────────────────────────────────────────────────────────

build:wheel:
stage: build
<<: *python_base
<<: *pip_cache
script:
- pip install build --quiet
- python -m build
- ls -la dist/
artifacts:
paths:
- dist/
expire_in: 1 month
rules:
# Only build on main branch or version tags
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/

Reasoning for pipeline design:

  • Parallel test jobs (test:python310, test:python311, test:python312): test matrix catches Python-version-specific bugs. Running in parallel means total test time is the slowest job, not the sum of all jobs.
  • when: always on test artifacts: JUnit reports are uploaded even when tests fail - GitLab needs the XML to show which tests failed in the MR UI. Without this, failed pipelines produce no test report artifacts.
  • coverage: '/TOTAL.*\s+(\d+%)$/': GitLab's regex to extract the coverage percentage from pytest-cov's terminal output. GitLab displays this in the MR UI and tracks it over time.
  • coverage_report: coverage_format: cobertura: Cobertura XML format is GitLab's native format for showing inline coverage in the MR diff view (which lines are covered, which are not).
  • YAML anchors (&anchor, <<: *anchor): DRY (Don't Repeat Yourself) for job configuration. The before_script and cache sections are defined once and reused across all jobs.
  • Build only on main and tags: no point building a distribution artifact for feature branches. Only builds that could be released need wheel/sdist artifacts.

noxfile.py

# noxfile.py
"""
Nox configuration for running tests across multiple Python versions.

Usage:
nox # run all sessions on all Python versions
nox -s tests # run tests on all configured Python versions
nox -s tests-3.11 # run tests on Python 3.11 only
nox -s lint # run lint checks
nox -s coverage # run tests with coverage report
nox -l # list all available sessions
"""

import nox

# Python versions to test against
PYTHON_VERSIONS = ["3.10", "3.11", "3.12"]

# Default sessions to run when `nox` is called with no arguments
nox.options.sessions = ["tests", "lint", "coverage"]


@nox.session(python=PYTHON_VERSIONS)
def tests(session: nox.Session) -> None:
"""Run the test suite on the specified Python version."""
session.install("-e", ".[dev]")
session.run(
"pytest",
"tests/",
"-m",
"not slow",
"--tb=short",
"-q",
f"--junitxml=reports/junit-{session.python}.xml",
*session.posargs, # allow extra args: nox -s tests -- -v tests/unit/
)


@nox.session(python=PYTHON_VERSIONS)
def tests_slow(session: nox.Session) -> None:
"""Run the full test suite including slow tests."""
session.install("-e", ".[dev]")
session.run(
"pytest",
"tests/",
"--tb=short",
"-q",
*session.posargs,
)


@nox.session(python="3.11")
def lint(session: nox.Session) -> None:
"""Run linting and type checking."""
session.install("ruff", "mypy", "types-requests", "types-PyYAML")
session.install("-e", ".")

session.log("Running Ruff...")
session.run("ruff", "check", "src/", "tests/")
session.run("ruff", "format", "--check", "src/", "tests/")

session.log("Running mypy...")
session.run("mypy", "src/", "--config-file", "pyproject.toml")


@nox.session(python="3.11")
def coverage(session: nox.Session) -> None:
"""Run tests with coverage and enforce the 85% branch coverage gate."""
session.install("-e", ".[dev]")
session.run(
"pytest",
"tests/",
"--cov=myproject",
"--cov-report=term-missing",
"--cov-report=html:reports/htmlcov",
"--cov-report=xml:reports/coverage.xml",
"--cov-branch",
"--cov-fail-under=85",
*session.posargs,
)


@nox.session(python="3.11")
def build(session: nox.Session) -> None:
"""Build the wheel and source distribution."""
session.install("build")
session.run("python", "-m", "build")
session.log("Build artifacts:")
session.run("ls", "-la", "dist/", external=True)


@nox.session(python="3.11")
def docs(session: nox.Session) -> None:
"""Build the documentation (if present)."""
session.install("-e", ".[dev]", "sphinx", "sphinx-rtd-theme")
session.run("sphinx-build", "-b", "html", "docs/", "docs/_build/html")

Why nox over tox:

  • noxfile.py is Python code, not INI configuration - conditional logic, loops, and helper functions are all available
  • nox.Session API is explicit and easy to read: session.install(), session.run()
  • session.posargs allows passing extra arguments: nox -s tests -- -v -k test_deposit passes -v -k test_deposit to pytest
  • Each session runs in its own fresh virtual environment by default - no cross-contamination
  • Supports --reuse-existing-virtualenvs (-R) flag for faster subsequent runs

Makefile

# Makefile for myproject
# Usage: make <target>
# Run 'make help' to see all available targets.

.PHONY: help install lint format typecheck test test-all coverage clean build all

# Default target
.DEFAULT_GOAL := help

# ── Python and tool settings ──────────────────────────────────────────────────

PYTHON := python3
PYTEST := pytest
RUFF := ruff
MYPY := mypy
NOX := nox

# Source and test directories
SRC_DIR := src
TEST_DIR := tests

# ── Help target ───────────────────────────────────────────────────────────────

help: ## Show this help message
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \%-20s \%s\n", $$1, $$2}'

# ── Setup ─────────────────────────────────────────────────────────────────────

install: ## Install the project and all dev dependencies
$(PYTHON) -m pip install --upgrade pip
$(PYTHON) -m pip install -e ".[dev]"
pre-commit install
pre-commit install --hook-type pre-push
@echo "Project installed. Pre-commit hooks installed."

install-hooks: ## Install only pre-commit hooks (no package reinstall)
pre-commit install
pre-commit install --hook-type pre-push

# ── Linting and formatting ────────────────────────────────────────────────────

lint: ## Run Ruff linter (check only, no auto-fix)
$(RUFF) check $(SRC_DIR)/ $(TEST_DIR)/
$(RUFF) format --check $(SRC_DIR)/ $(TEST_DIR)/

lint-fix: ## Run Ruff linter with auto-fix
$(RUFF) check --fix $(SRC_DIR)/ $(TEST_DIR)/
$(RUFF) format $(SRC_DIR)/ $(TEST_DIR)/

format: ## Run Black formatter
$(PYTHON) -m black $(SRC_DIR)/ $(TEST_DIR)/

format-check: ## Check formatting without modifying files
$(PYTHON) -m black --check $(SRC_DIR)/ $(TEST_DIR)/

typecheck: ## Run mypy type checking
$(MYPY) $(SRC_DIR)/ --config-file pyproject.toml

# ── Testing ───────────────────────────────────────────────────────────────────

test: ## Run fast tests (excludes tests marked 'slow')
$(PYTEST) $(TEST_DIR)/ -m "not slow" --tb=short -q

test-all: ## Run all tests including slow tests
$(PYTEST) $(TEST_DIR)/ --tb=short -q

test-unit: ## Run only unit tests
$(PYTEST) $(TEST_DIR)/unit/ --tb=short -v

test-integration: ## Run only integration tests
$(PYTEST) $(TEST_DIR)/integration/ --tb=short -v

test-property: ## Run only property-based tests (Hypothesis)
$(PYTEST) $(TEST_DIR)/property/ --tb=short -v

test-matrix: ## Run tests across all Python versions with nox
$(NOX) -s tests

# ── Coverage ──────────────────────────────────────────────────────────────────

coverage: ## Run tests with coverage report (fails if below 85\%)
$(PYTEST) $(TEST_DIR)/ \
--cov=$(SRC_DIR)/myproject \
--cov-report=term-missing \
--cov-report=html:reports/htmlcov \
--cov-branch \
--cov-fail-under=85

coverage-html: coverage ## Run coverage and open the HTML report
$(PYTHON) -m webbrowser reports/htmlcov/index.html

# ── Pre-commit ────────────────────────────────────────────────────────────────

pre-commit: ## Run all pre-commit hooks against all files
pre-commit run --all-files

pre-commit-update: ## Update all pre-commit hooks to their latest versions
pre-commit autoupdate

# ── Build ─────────────────────────────────────────────────────────────────────

build: ## Build wheel and source distribution
$(PYTHON) -m build
@echo "Build complete. Artifacts in dist/:"
@ls -la dist/

# ── Cleaning ──────────────────────────────────────────────────────────────────

clean: ## Remove all build artifacts, caches, and generated files
rm -rf dist/
rm -rf build/
rm -rf *.egg-info
rm -rf src/*.egg-info
rm -rf reports/
rm -rf .coverage
rm -rf .pytest_cache/
rm -rf .mypy_cache/
rm -rf .ruff_cache/
rm -rf .nox/
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
@echo "Clean complete."

clean-cache: ## Remove only cache directories (keep dist/ and reports/)
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/ .nox/
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true

# ── Combined targets ──────────────────────────────────────────────────────────

check: lint typecheck ## Run all static analysis (lint + type check)

ci: lint typecheck test coverage ## Run the same checks as CI (local simulation)

all: clean install check test-all coverage build ## Full pipeline: clean → install → check → test → coverage → build

Reasoning for the Makefile design:

  • ## comment on each target enables the make help autodiscovery pattern: grep -E '^[a-zA-Z_-]+:.*?## ' extracts target names and descriptions automatically
  • .PHONY on all targets: prevents confusion if a file named test or build exists in the project root
  • .DEFAULT_GOAL := help: make with no arguments shows help, not accidentally runs all
  • Separate lint (check-only) and lint-fix (auto-fix) targets: developers in CI use lint; developers locally use lint-fix when they want auto-fixes
  • ci target: let developers run make ci locally to simulate exactly what CI runs, before pushing

scripts/generate_coverage_badge.py

#!/usr/bin/env python3
"""
Generate a Shields.io compatible coverage badge JSON from a coverage.xml file.

Usage:
python scripts/generate_coverage_badge.py reports/coverage.xml public/coverage-badge.json
"""

import json
import sys
import xml.etree.ElementTree as ET
from pathlib import Path


def get_coverage_color(pct: float) -> str:
if pct >= 90:
return "brightgreen"
elif pct >= 80:
return "green"
elif pct >= 70:
return "yellow"
elif pct >= 60:
return "orange"
else:
return "red"


def generate_badge(xml_path: str, output_path: str) -> None:
tree = ET.parse(xml_path)
root = tree.getroot()

# coverage.xml stores line-rate as a float (e.g., 0.876 = 87.6%)
line_rate = float(root.attrib.get("line-rate", "0"))
branch_rate = float(root.attrib.get("branch-rate", "0"))

# Use branch coverage for the badge (stricter metric)
coverage_pct = branch_rate * 100
color = get_coverage_color(coverage_pct)

badge = {
"schemaVersion": 1,
"label": "coverage",
"message": f"{coverage_pct:.0f}%",
"color": color,
}

output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(badge, indent=2))
print(f"Coverage badge written to {output_path}: {coverage_pct:.0f}% ({color})")


if __name__ == "__main__":
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <coverage.xml> <output.json>")
sys.exit(1)
generate_badge(sys.argv[1], sys.argv[2])

Verification Checklist

Before submitting this project, verify each item:

  • pre-commit run --all-files passes with no violations
  • make lint exits 0
  • make typecheck exits 0
  • make test runs and passes (fast tests only)
  • make coverage passes and shows >= 85% branch coverage
  • make build produces files in dist/
  • make help shows all targets with descriptions
  • .gitlab-ci.yml is valid YAML (run check-yaml hook or python -c "import yaml; yaml.safe_load(open('.gitlab-ci.yml'))")
  • nox -l lists all sessions correctly
  • nox -s lint passes
  • nox -s tests-3.11 passes (if Python 3.11 is available)
  • .secrets.baseline exists and detect-secrets scan --baseline .secrets.baseline exits 0

Key Design Decisions and Trade-offs

DecisionChoiceAlternativeReasoning
Build backendhatchlingsetuptools, flitZero-config for src/ layout; fast; modern
Test runnerpytestunittestFixtures, plugins, parametrize
Multi-version testingnoxtoxPython-native config; easier conditionals
Linterruffflake8 + pluginsSingle tool, 10-100x faster, same rules
Formatterruff-format (Black-compatible)BlackSingle tool covers both; same output
Type checkermypypyrightStandard; integrates with pre-commit mirrors
Coverage threshold85% branch80% lineBranch coverage catches missing else-branches
CI platformGitLab CIGitHub ActionsYAML anchors reduce repetition; native coverage display

Extension Challenges

Extension 1 - Add GitHub Actions

Port the .gitlab-ci.yml to a .github/workflows/ci.yml file. Key differences: GitLab uses artifacts:reports:junit: while GitHub uses actions/upload-artifact; GitHub uses a job matrix with strategy.matrix instead of duplicate jobs.

Extension 2 - Dependabot for pre-commit hook updates

Create .github/dependabot.yml (or the GitLab equivalent) that automatically opens PRs when pre-commit hook versions are outdated. This replaces the need to manually run pre-commit autoupdate.

Extension 3 - Mutation testing in CI

Add a mutation stage after the coverage stage that runs mutmut:

mutation:
stage: mutation
script:
- pip install mutmut
- mutmut run --paths-to-mutate src/
- mutmut results
- mutmut junitxml > reports/mutmut-junit.xml
artifacts:
reports:
junit: reports/mutmut-junit.xml
allow_failure: true # don't block merge on mutation regressions initially
rules:
- if: $CI_COMMIT_BRANCH == "main"

Mutation testing measures test quality, not just coverage. The goal: 0 surviving mutants on critical code paths.

Extension 4 - Signed releases

Add a release stage that uses cosign or sigstore to sign the wheel artifact before publishing to PyPI:

pip install sigstore
python -m sigstore sign dist/*.whl dist/*.tar.gz

This produces .sigstore files that consumers can verify, proving the artifact was built by your CI pipeline and not tampered with.

© 2026 EngineersOfAI. All rights reserved.