You have a Python CLI repo and you want every commit to be linted, formatted, type-checked, and tested before it lands — without relying on people remembering to run those tools by hand. This guide takes you from a clean clone to a green continuous-integration run: install pre-commit, write a complete .pre-commit-config.yaml, add the matching ruff and mypy config to pyproject.toml, install the Git hook, run it across the whole repo, and pin and update the hook versions. Everything targets Python 3.10+.
TL;DR
# 1. install the framework (into your project's dev environment)
pip install pre-commit
# 2. add .pre-commit-config.yaml and the [tool.ruff]/[tool.mypy] config below
# 3. install the Git hook so it runs on every commit
pre-commit install
# 4. run all hooks once across the existing codebase
pre-commit run --all-files
After step 3, every git commit runs ruff (lint + format), mypy, and your test suite automatically. Read on for the full config and the reasoning.
Step 1: install pre-commit
pre-commit is itself a Python package. Install it into the same dev environment you use for the project so the version is reproducible:
pip install pre-commit
pre-commit --version
Add it to your project's dev dependencies as well — that way CI and new contributors get the same version. In a PEP 621 pyproject.toml that is a dependency group:
[dependency-groups]
dev = [
"pre-commit>=4.0",
"ruff>=0.6",
"mypy>=1.11",
"pytest>=8.0",
]
If you manage dependencies with uv, see uv for Python CLI dependency management for keeping that group locked.
Step 2: write the .pre-commit-config.yaml
Create .pre-commit-config.yaml at the repository root. This is the complete config for a CLI project — ruff for linting, ruff for formatting, mypy for type checking, and a local hook that runs pytest:
# .pre-commit-config.yaml
default_language_version:
python: python3.10
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
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
name: ruff (lint)
args: [--fix]
- id: ruff-format
name: ruff (format)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
hooks:
- id: mypy
additional_dependencies:
- typer>=0.12
args: [--strict]
- repo: local
hooks:
- id: pytest
name: pytest (fast suite)
entry: pytest
language: system
types: [python]
pass_filenames: false
stages: [pre-push]
A few details that matter for CLIs:
ruffwith--fixauto-corrects safe lint violations (unused imports, import sorting) and re-stages them.ruff-formatthen applies the deterministic style. Running both from the same tool avoids the formatter/linter rule conflicts you get when mixing black and flake8.mypywithadditional_dependencies— pre-commit runs mypy in an isolated environment, so it can't see your project's installed packages. List the typed libraries your CLI imports (here Typer) so mypy can resolve their stubs. Addclick,rich, or whatever your CLI depends on.- The local
pytesthook runs the test suite. It's set tolanguage: system, meaning it uses thepytestalready on your PATH (in your dev env) rather than a pre-commit-managed one.pass_filenames: falsestops pre-commit from passing changed filenames as test paths. It's gated tostages: [pre-push]so the full suite runs on push rather than slowing every single commit — move it to the default stage if you want it on every commit.
Step 3: add the matching pyproject.toml config
The hooks above call ruff and mypy, but those tools read their own configuration from pyproject.toml. Without it, ruff and mypy use defaults that may not match what the hooks expect. Add these tables:
[tool.ruff]
line-length = 88
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.mypy]
python_version = "3.10"
strict = true
warn_unused_ignores = true
warn_return_any = true
files = ["src", "tests"]
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false
What these do: target-version = "py310" lets ruff's UP rules rewrite code to modern 3.10 syntax (for example, X | None instead of Optional[X]). The B and SIM rule sets catch the bug-prone patterns common in argument-handling code. The mypy overrides block relaxes the strict "every function must be annotated" rule for tests, where it's noise rather than signal.
Step 4: install the hook
Installing the Git hook is what makes pre-commit run automatically:
pre-commit install
This writes a pre-commit script into .git/hooks/. From now on, every git commit runs the hooks against your staged files. Because the local pytest hook is gated to pre-push, also install that stage:
pre-commit install --hook-type pre-push
If a hook modifies a file (ruff's --fix or the formatter), the commit aborts and the fixes are left in your working tree — review them, git add, and commit again.
Step 5: run against the whole repo
When you first adopt pre-commit, run every hook across the entire codebase, not just staged files. This surfaces the backlog of existing issues in one go:
pre-commit run --all-files
Expect the first run to make formatting and import-sorting changes. Commit those as a single "apply pre-commit" change so the diff is isolated and easy to review. After that, runs are incremental and fast.
Step 6: pin and update hook versions
Notice every remote hook in the config has a rev: field pinned to a specific tag (v0.6.9, v1.11.2). This is deliberate — it guarantees everyone runs the identical version, and your checks don't change underneath you when an upstream tool ships new rules. Never leave rev floating.
To upgrade those pins on your schedule, run:
pre-commit autoupdate
This rewrites each rev to the latest tag of each hook repository. Run it periodically (a monthly dependency-bump PR is a good cadence), then run pre-commit run --all-files to absorb any new lint or format changes the upgrade introduced, and commit the result. Bumping these pins is part of the same release hygiene as managing CLI versioning & changelogs.
Step 7: run it in CI
Local hooks are convenient but skippable (git commit --no-verify). CI is the enforcement backstop. Run the exact same config in your pipeline so nothing slips through. For GitHub Actions:
# .github/workflows/lint.yml
name: lint
on: [push, pull_request]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install pre-commit
- run: pre-commit run --all-files --show-diff-on-failure
--all-files checks the whole repo (CI has no staged-file concept), and --show-diff-on-failure prints exactly what a formatter or fixer would have changed, so a failing run is self-explanatory. Because both local and CI runs read the same .pre-commit-config.yaml, they check identical things — there is one source of truth.
Why this setup works
The combination is fast where it needs to be and strict where it counts. Ruff runs in milliseconds, so lint and format gate every commit without friction. mypy in --strict mode catches the type errors that bite CLIs hardest — the unguarded None flowing into a path or subprocess call. pytest gates on push, so the slower behavioral check doesn't tax every tiny commit but still blocks a broken suite from reaching the remote. And every version is pinned, so the checks are reproducible across your laptop, a teammate's, and CI.
When not to reach for all of this: a throwaway script doesn't need a pytest gate. But the moment a CLI has a published entry point and real users, the cost of a regression dwarfs the few minutes of setup here.
Production notes
- Isolated mypy environments: the most common failure is mypy reporting
import-untypedor missing-import errors for libraries it can't see. The fix is alwaysadditional_dependencieson the mypy hook — list every typed third-party package your code imports. - Monorepos: if your CLI lives in a subdirectory, set
files:patterns (or ruff'ssrc) so hooks don't scan unrelated code. - CI caching: pre-commit caches its hook environments under
~/.cache/pre-commit. Cache that directory in CI (keyed on the hash of.pre-commit-config.yaml) to cut minutes off each run. --no-verifydiscipline: treat a local bypass as a temporary escape hatch only — CI will still catch it, which is exactly why the CI gate is non-negotiable.
Related
- Pre-commit hooks for CLI projects — the hub explaining the gate philosophy behind this setup.
- Project Setup & Dependency Management — the pillar this guide belongs to.
- uv for Python CLI dependency management — keep the dev dependencies that back these hooks locked and reproducible.