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 asOptional[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 #
| Tool | Role | Notes |
|---|---|---|
| ruff | linting + formatting + import sorting | flake8 + black + isort merged. Written in Rust, very fast |
| mypy | type checking (reference implementation) | Oldest, broadest compatibility |
| pyright | type checking (Microsoft) | Faster than mypy with stronger inference. VS Code’s Pylance uses the same engine |
| pre-commit | auto-run at the local commit stage | Framework that wires the above tools together |
| GitHub Actions | re-run the same checks at the PR stage | Safety 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 ruffpyproject.toml config #
[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 #
uv run ruff check .uv run ruff check . --fixuv 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 pyrightpyproject.toml config #
[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 pyrightTo 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-commitConfig file #
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
- sqlalchemypyright 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 #
uv run pre-commit installuv run pre-commit run --all-filesAfter 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.
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.xmlA few patterns:
concurrency— consecutive pushes to the same PR auto-cancel the previous run. Saves CI time.uv sync --frozen— environment that matchesuv.lockexactly. 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 looseDefault 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 markerYou have three options.
- Check if
types-some_libexists on PyPI (uv add --dev types-some_lib) - Write stubs yourself (
stubs/some_lib.pyi) - 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 autoupdateThis 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 #
- Extending ruff’s select — Add
N(naming) andD(docstring) to this chapter’s defaultselect. Which errors come up most often at first? How would you writeper-file-ignoresto relax only some rules in the tests folder? - 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 missingOptional,Anycontagion, and missing return types — which appears most? - 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’senable-cache: trueworks, then compare the second run vs. the first run on a PR whereuv.lockhasn’t changed.
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.”