Contents
30 Chapter

Type checker setup and CI integration

mypy/pyright/ruff configuration and how to avoid conflicts, blocking issues locally with pre-commit, then blocking them at the PR stage with GitHub Actions.

This is the start of Part 5 (Operations, Packaging, Testing). If Parts 1–4 were “how to build features,” Part 5 is “how to refine that code into something operable.” This chapter is the first step — automating type checking.

This whole book is written with a type-hints-first mindset. The expressive power you saw in Chapter 9 typing · Generic · Protocol and Chapter 20 advanced typing loosens over time unless tools enforce it. This chapter covers how to enforce that discipline with tooling.

Why static verification is needed #

There are things tests alone can’t catch.

  • Type errors on uncalled code paths — branches the tests never reach.
  • Missing None handling — calling .foo() on something you took as Optional[X].
  • Stale call sites after an API change — when a library version bump removes an argument and old call sites linger.
  • Wrong dict keys / wrong enum values — typos that only blow up at runtime.

Static analysis catches these without running the code, across every branch. It’s a complement to unit tests, not a replacement.

Tool roles — what does what #

ToolRoleNotes
rufflinting + formatting + import sortingflake8 + black + isort merged. Written in Rust, very fast
mypytype checking (reference implementation)Oldest, broadest compatibility
pyrighttype checking (Microsoft)Faster than mypy with stronger inference. VS Code’s Pylance uses the same engine
pre-commitauto-run at the local commit stageFramework that wires the above tools together
GitHub Actionsre-run the same checks at the PR stageSafety net for changes that bypass local hooks

The core principle is same tool, same version, same config on local and CI. Trust collapses if something passes locally and fails in CI.

mypy vs pyright — which to pick #

Both are static analyzers that check PEP 484 type hints, but their characters differ.

mypy

  • Python’s reference implementation. New PEPs land here first.
  • Inference is weaker — it sometimes re-widens a variable after narrowing in a later branch.
  • Library compatibility is broader — it reads old stubs well.

pyright

  • Built by Microsoft. VS Code’s Pylance uses the same engine.
  • Inference is very strong — it holds onto narrowed types well.
  • Strict mode (strict) is very strict; turning it on cold explodes errors.
  • Faster than mypy.

Practical recommendation: if you must pick one, pick pyright. The editor and the CLI use the same engine, so consistency is good. That said, when external libraries lack stubs, mypy is sometimes more forgiving.

This book uses pyright as the baseline.

ruff — linting · formatting · import sorting in one tool #

Before ruff appeared, you installed flake8 + black + isort + pyupgrade separately, kept config for each, and registered 4 hooks in pre-commit. Now that role is unified into a single tool, ruff, written in Rust and far faster than before.

Install #

uv add --dev ruff

pyproject.toml config #

pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py314"
src = ["src", "app", "tests"]

[tool.ruff.lint]
select = [
  "E",    # pycodestyle errors
  "W",    # pycodestyle warnings
  "F",    # pyflakes
  "I",    # isort
  "B",    # flake8-bugbear (common bug patterns)
  "C4",   # flake8-comprehensions
  "UP",   # pyupgrade (suggests new syntax matching Python version)
  "RUF",  # ruff's own rules
]
ignore = [
  "E501",  # line too long — formatter handles this
]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]  # allow assert in tests

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

select is the categories of rules to enable. Start with the set above and once you’re used to it, add N (naming), D (docstrings), ANN (annotation enforcement), etc.

Run #

check
uv run ruff check .
auto-fix
uv run ruff check . --fix
format
uv run ruff format .

It matters that check and format are separated. ruff check says “this code is wrong”; ruff format says “unify the shape of this code.”

pyright — type check configuration #

Install #

uv add --dev pyright

pyproject.toml config #

pyproject.toml
[tool.pyright]
include = ["src", "app", "tests"]
exclude = ["**/__pycache__", "**/node_modules", "build", "dist"]
pythonVersion = "3.14"
typeCheckingMode = "standard"  # off / basic / standard / strict

# Incremental adoption — flip per module
executionEnvironments = [
  { root = "app/core", reportMissingTypeStubs = "error" },
  { root = "app/services", reportMissingTypeStubs = "error" },
  { root = "tests", reportMissingTypeStubs = "none", reportPrivateUsage = "none" },
]

# Individual rule tweaks
reportUnusedImport = "warning"
reportUnusedVariable = "warning"
reportMissingTypeStubs = "warning"
reportImplicitOverride = "error"

typeCheckingMode is the key.

  • off — effectively no checking even when enabled
  • basic — only the obvious errors
  • standard — reasonable defaults (recommended starting point)
  • strict — every check on. Turning it on at once on a large codebase yields hundreds of errors

Run #

uv run pyright

To check only a specific path: uv run pyright app/services/.

pre-commit — blocking at the local commit stage #

pre-commit is a Git hook framework. It runs the registered checks just before git commit and blocks the commit on failure.

Install #

uv add --dev pre-commit

Config file #

.pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-toml
      - id: check-added-large-files
        args: [--maxkb=500]
      - id: check-merge-conflict

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/RobertCraigie/pyright-python
    rev: v1.1.390
    hooks:
      - id: pyright
        additional_dependencies:
          - fastapi
          - pydantic
          - sqlalchemy

pyright needs the external library installed in the same environment to read its type stubs. pre-commit runs hooks in isolated venvs, so list them under additional_dependencies.

Install and first run #

install git hook
uv run pre-commit install
run once across the whole repo
uv run pre-commit run --all-files

After this, on every git commit the hooks run on changed files automatically. The commit is blocked on failure.

Temporarily skipping a hook #

For emergency hotfixes you can skip with git commit --no-verify. The PR stage will catch the same issue, so you’ll still have to fix it.

GitHub Actions — the PR-stage safety net #

The second net that catches changes that bypassed local hooks, or were authored in a different environment.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v4
        with:
          enable-cache: true

      - name: Set up Python
        run: uv python install 3.14

      - name: Install dependencies
        run: uv sync --frozen

      - name: Ruff check
        run: uv run ruff check .

      - name: Ruff format check
        run: uv run ruff format --check .

      - name: Pyright
        run: uv run pyright

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: astral-sh/setup-uv@v4
        with:
          enable-cache: true

      - run: uv python install 3.14
      - run: uv sync --frozen

      - name: Run tests
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test
        run: uv run pytest -v --cov=app --cov-report=xml

      - uses: codecov/codecov-action@v5
        with:
          files: ./coverage.xml

A few patterns:

  • concurrency — consecutive pushes to the same PR auto-cancel the previous run. Saves CI time.
  • uv sync --frozen — environment that matches uv.lock exactly. Fails if the lock is out of sync.
  • Two separate jobs — lint/typecheck finishes quickly, so it runs in parallel with test. Faster feedback.

Incremental typing adoption strategy #

Flipping strict on an existing codebase all at once dumps hundreds of errors that you’ll never finish — you have to phase it in.

Step 1 — start with basic

[tool.pyright]
typeCheckingMode = "basic"

Catches only obvious errors (missing None, attribute typos). Mostly passes if you already have type hints.

Step 2 — promote to standard

typeCheckingMode = "standard"

Adds extra checks (missing return types, Optional handling, etc.).

Step 3 — per-module strict

[[tool.pyright.executionEnvironments]]
root = "app/core"
typeCheckingMode = "strict"

[[tool.pyright.executionEnvironments]]
root = "app/services"
typeCheckingMode = "strict"

Raise newly written / important modules to strict first.

Step 4 — strict everywhere, manage the exception list

typeCheckingMode = "strict"

[[tool.pyright.executionEnvironments]]
root = "app/legacy"
typeCheckingMode = "standard"  # leave legacy loose

Default is strict; loose spots are explicitly registered as exceptions. The size of the loose area is the size of your tech debt.

Step 5 — 100% strict

Keep moving modules until legacy empties out.

Common pitfalls #

External library has no type stubs #

error: Skipping analyzing "some_lib": module is installed, but missing library stubs or py.typed marker

You have three options.

  1. Check if types-some_lib exists on PyPI (uv add --dev types-some_lib)
  2. Write stubs yourself (stubs/some_lib.pyi)
  3. Ignore just that import (# pyright: ignore[reportMissingTypeStubs])

pyright and mypy disagree #

The two tools use different inference algorithms. Don’t try to run both — pick one as the source of truth and use the other only as an IDE assist. This book treats pyright as the source of truth.

Any contagion #

If one function returns Any, every caller of it also becomes Any. Strict rules like reportUnknownReturnType and reportAny catch this, but the starting principle is: “Any only at the boundary with external libraries; no Any inside our code.”

ruff and formatter conflicts #

ruff can handle both linting and formatting, so there is no conflict inside ruff itself. If you keep external black alongside it, the rules can diverge. In this book, use ruff format alone.

pre-commit hook versions go stale #

rev: in .pre-commit-config.yaml is pinned, so you need to bump it periodically.

uv run pre-commit autoupdate

This refreshes every repo’s rev: to the latest. Once a quarter or so.

Applying it to the Chapter 29 capstone #

Apply this chapter’s setup to the project built in Chapter 29 TODO API capstone and the polish jumps a level.

cd todo-api/
uv add --dev ruff pyright pre-commit
# Add the pyproject.toml / .pre-commit-config.yaml / .github/workflows/ci.yml above
uv run pre-commit install
uv run pre-commit run --all-files  # first pass
git add . && git commit -m "chore: setup typecheck and CI"

Now main only accepts code that passed type checking, lint, format, and tests.

Exercises #

  1. Extending ruff’s select — Add N (naming) and D (docstring) to this chapter’s default select. Which errors come up most often at first? How would you write per-file-ignores to relax only some rules in the tests folder?
  2. pyright executionEnvironments — In your own project (use the Chapter 29 TODO API if you don’t have one), turn one directory up to typeCheckingMode = "strict". Which errors are most common? Of missing Optional, Any contagion, and missing return types — which appears most?
  3. CI cache optimization — The GitHub Actions workflow above reinstalls dependencies on every run for both the lint-and-typecheck job and the test job. Check how astral-sh/setup-uv@v4’s enable-cache: true works, then compare the second run vs. the first run on a PR where uv.lock hasn’t changed.
Note
In one line — Two tools, ruff (lint·format) and pyright (types), are enough for static verification. Block them locally with pre-commit and at the PR stage with GitHub Actions — that double layer is what keeps the codebase’s type safety intact over time.

The next chapter is logging and observability. With static verification you’ve blocked “the correctness of the code”; the next step is “making what happens during operation visible.”

X