Expert · Lesson 86 of 94

Code quality tooling and CI

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Quality tooling turns "please remember to" into "it is impossible to forget". A linter reads code and reports likely bugs, unused imports, and style violations; a formatter rewrites code into one consistent layout so nobody argues about it; a type checker verifies the hints; tests verify behavior. ruff does the first two, replacing flake8, isort, black, and a dozen plugins with one tool that runs in milliseconds. With mypy and pytest, that is the complete set, and the rest of this lesson is about making them run without anyone deciding to.

Code quality tooling and CI: edit → save: editor runs ruff format → git commit: pre-commit runs ruff, mypy, quick tests → git push: CI runs everything on clean machines → merge only when green
Code quality tooling and CI · Figure 1 · Open for full size

ruff check lints and ruff format formats; both read [tool.ruff] in pyproject.toml. Start with the default rule set plus a few families, E and F for errors, I for import sorting, UP for modern syntax, B for common bugs, and let ruff check --fix correct what it can. Set line-length once and stop discussing it. Mypy's [tool.mypy] with strict = true is the goal for new projects; for existing code, start permissive and tighten module by module. Committed configuration means the checks are identical for every developer and for CI.

Tool Job Command Config
ruff check lint: bugs, unused code, imports ruff check --fix . [tool.ruff], [tool.ruff.lint]
ruff format format: one layout, no debate ruff format . [tool.ruff.format]
mypy verify type hints mypy src [tool.mypy]
pytest verify behavior pytest [tool.pytest.ini_options]
pre-commit run the above before each commit pre-commit run --all-files .pre-commit-config.yaml
CI workflow run the above on every push automatic .github/workflows/ci.yml

pre-commit installs a Git hook that runs configured checks on the files you are about to commit and blocks the commit if any fail. The configuration, .pre-commit-config.yaml, lists hooks with pinned versions; ruff and mypy publish official hooks, and a local hook can run pytest. It catches problems in seconds, before they reach a branch, and it fixes formatting for you. Everyone on the project runs pre-commit install once and then never thinks about formatting again.

Continuous integration runs the same checks on a clean machine every time code is pushed, and blocks merging when they fail. It exists because local hooks can be skipped, environments drift, and "works on my machine" is not evidence. A GitHub Actions workflow is a YAML file: on push and pull request, check out the code, install the pinned Python and dependencies with uv sync, then run ruff, mypy, and pytest. A matrix can run the tests on several Python versions and operating systems. The workflow file lives in the repository, so the pipeline is versioned with the code it checks, and a second workflow on a v* tag can build and publish the package with the token from the repository's secrets.

Code quality tooling and CI: push / pull request → job: ubuntu, Python 3.14.7 → actions/checkout → astral-sh/setup-uv → uv sync → run the four checks
Code quality tooling and CI · Figure 2 · Open for full size

The job body is the same four commands you ran locally, in the same order: lint, format check, types, tests. The order matters only for speed, cheapest checks first, because the first failure stops the job and marks the pull request red. A green run is what lets the merge button appear, so the checks are a gate, not a report.

Code quality tooling and CI: uv run ruff check . && uv run ruff format --check . → uv run mypy src → uv run pytest → merge allowed → red check on the PR
Code quality tooling and CI · Figure 3 · Open for full size

Code

The tool configuration, all in pyproject.toml, and a file with deliberate problems for the tools to find: an unused import, an f-string with nothing in it, an old-style type hint, a formatting mess, and a real type error.

# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py314"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

[tool.ruff.format]
quote-style = "double"

[tool.mypy]
strict = true
# messy.py
import os
import sys
from typing import List, Optional

def total(prices: List[float], tax: Optional[float]=None) -> float:
    rate=tax if tax is not None else 0.0
    print(f"computing")
    return sum(prices)*(1+rate)

def label(n: int) -> str:
    return "items: " + n

ruff check finds the lint problems and fixes the safe ones; ruff format fixes the layout; the type error is left for mypy, which is the point of running both. The script exits non-zero because mypy does, which is exactly what a hook or CI job needs.

# lint_format.sh
ruff check messy.py 2>&1 | grep -E "^[A-Z]+[0-9]+ |Found|fixable" | sed -E 's/\x1b\[[0-9;]*m//g'
ruff check --fix -q messy.py; ruff format -q messy.py
echo "--- after ruff:"
cat messy.py
echo "--- mypy:"
mypy messy.py
Output
I001 [*] Import block is un-sorted or un-formatted
F401 [*] `os` imported but unused
F401 [*] `sys` imported but unused
UP035 `typing.List` is deprecated, use `list` instead
UP006 [*] Use `list` instead of `List` for type annotation
UP045 [*] Use `X | None` for type annotations
F541 [*] f-string without any placeholders
Found 7 errors.
[*] 6 fixable with the `--fix` option.
--- after ruff:
def total(prices: list[float], tax: float | None = None) -> float:
    rate = tax if tax is not None else 0.0
    print("computing")
    return sum(prices) * (1 + rate)


def label(n: int) -> str:
    return "items: " + n
--- mypy:
messy.py:8: error: Returning Any from function declared to return "str"  [no-any-return]
messy.py:8: error: Unsupported operand types for + ("str" and "int")  [operator]
Found 2 errors in 1 file (checked 1 source file)

The pre-commit configuration with pinned hooks, and a dry run over all files. Installing it into a repository is one command.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.16.6
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v2.3.1
    hooks:
      - id: mypy
        args: [--strict]
  - repo: local
    hooks:
      - id: pytest
        name: pytest (fast tests)
        entry: pytest -q -x
        language: system
        pass_filenames: false
# install_hooks.sh
pre-commit install                 # once per clone: writes .git/hooks/pre-commit
pre-commit run --all-files         # run everything now; later it runs on each commit automatically
git commit -m "..."                # blocked if any hook fails; ruff's fixes are staged for you to re-add

The CI workflow. It runs the identical commands on push and pull request, with the exact Python and locked dependencies, and a matrix shows how to add versions.

# .github/workflows/ci.yml
name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ["3.14.7"]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv python install ${{ matrix.python }}
      - run: uv sync --python ${{ matrix.python }}
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run mypy src
      - run: uv run pytest -q
# .github/workflows/release.yml
name: release

on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv build
      - run: uv publish
        env:
          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}

The same checks as a single local script, which is what CI runs and what you run before pushing. Exit codes make it usable from any tool.

# check_all.sh
set -e
printf 'def greet(name: str) -> str:\n    return f"hi {name}"\n' > clean.py
printf 'from clean import greet\n\n\ndef test_greet() -> None:\n    assert greet("ada") == "hi ada"\n' > test_clean.py
ruff check clean.py test_clean.py && echo "ruff check: ok"
ruff format --check clean.py test_clean.py && echo "ruff format: ok"
mypy --strict clean.py test_clean.py | tail -1
pytest -q test_clean.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/' | tail -1
echo "all checks passed: exit $?"
Output
All checks passed!
ruff check: ok
2 files already formatted
ruff format: ok
Success: no issues found in 2 source files
1 passed in Ns
all checks passed: exit 0

Try it yourself

Add [tool.ruff] and [tool.mypy] to the expense project's pyproject.toml, run ruff check --fix . and ruff format ., and commit the result as one "apply formatting" commit. Add .pre-commit-config.yaml with the hooks above, run pre-commit install and pre-commit run --all-files, then try to commit a file with an unused import and watch the hook block it. Push the repository to GitHub with .github/workflows/ci.yml and confirm the check runs and passes on the pull request.

Common mistakes

What's next

Quality is now automatic. Next you will put the finished project into a container and run it the way production does, with Docker.

Course overview