Project Setup

Setting up pre-commit for Python CLI repos

Step-by-step guide to installing and configuring pre-commit in Python CLI repositories with ruff, black, and mypy hooks for Python 3.10+.

Updated

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.

Four-step pre-commit setup flow as numbered cards: pip install pre-commit, add a .pre-commit-config.yaml, run pre-commit install which writes the .git/hooks script, then run pre-commit run --all-files.

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:

  • ruff with --fix auto-corrects safe lint violations (unused imports, import sorting) and re-stages them. ruff-format then applies the deterministic style. Running both from the same tool avoids the formatter/linter rule conflicts you get when mixing black and flake8.
  • mypy with additional_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. Add click, rich, or whatever your CLI depends on.
  • The local pytest hook runs the test suite. It's set to language: system, meaning it uses the pytest already on your PATH (in your dev env) rather than a pre-commit-managed one. pass_filenames: false stops pre-commit from passing changed filenames as test paths. It's gated to stages: [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-untyped or missing-import errors for libraries it can't see. The fix is always additional_dependencies on 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's src) 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-verify discipline: 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.