Project Setup

Writing Local pre-commit Hooks in Python

Write project-specific pre-commit hooks for a Python CLI repo: pygrep, python and system hooks, keeping generated docs in sync, the hook contract and testing.

Updated

Some checks only make sense for your repository. The generated CLI reference in docs/ must match the current --help output. Every command module must be registered in the command table. Nobody should commit a breakpoint(). The version in the changelog heading must be valid. No public hook exists for these, and writing a whole published package for a twenty-line check is overkill. pre-commit's local hooks fill the gap: hooks defined directly in your .pre-commit-config.yaml under repo: local, running a regular expression, a script in the repository, or a small Python program. This guide shows the four ways to write a local hook, when to use each, the contract a hook must follow, and how to test hooks so they do not become the flaky step everyone skips with --no-verify. It belongs to the pre-commit hooks for CLI projects topic.

Prerequisites

Four ways to write a local hook

Ways to write a local hook The pre-commit hook languages suitable for local project hooks, how each is installed, and when to choose it. Ways to write a local hook language: Environment Choose when pygrep none — a regex forbid a pattern python isolated venv hook needs its own deps system whatever is on PATH must use the project env script a script in the repo small shell glue python with additional_dependencies is the reproducible default; system trades that for access to your project.

pygrep runs a Python regular expression over the matching files and fails on any match. No code and no environment — ideal for forbidding a pattern.

python builds an isolated virtual environment and runs an entry point in it, with any additional_dependencies you list. Reproducible and independent of the developer's setup, but it cannot import your project unless you install it into that environment.

system runs whatever command is on the developer's PATH. It is the only way to run something inside your project's environment — for example your own CLI through uv run — at the cost of depending on the developer having that environment.

script runs a script file from the repository, with no environment management. Handy for small shell or Python glue with no dependencies.

The recipe

Here is a .pre-commit-config.yaml with one local hook of each useful kind, all solving real problems in a CLI repository:

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: no-breakpoints
        name: no breakpoint() or pdb in source
        language: pygrep
        entry: '\bbreakpoint\(\)|\bimport pdb\b|\bpdb\.set_trace\('
        types: [python]
        exclude: ^tests/fixtures/

      - id: changelog-heading
        name: changelog headings are valid versions
        language: python
        entry: python scripts/hooks/check_changelog.py
        files: ^CHANGELOG\.md$
        additional_dependencies: ["packaging>=24"]

      - id: cli-reference-up-to-date
        name: CLI reference is up to date
        language: system
        entry: uv run --frozen python scripts/hooks/check_cli_docs.py
        files: ^(src/mytool/.*\.py|docs/reference\.md)$
        pass_filenames: false

A hook that checks files it is given

The changelog hook is a normal Python script that receives filenames, checks each, prints problems as file:line: message, and returns non-zero if anything is wrong:

# scripts/hooks/check_changelog.py
"""Every '## [x.y.z]' heading in the changelog must be a valid PEP 440 version."""
from __future__ import annotations

import re
import sys
from pathlib import Path

from packaging.version import InvalidVersion, Version

HEADING = re.compile(r"^## \[(?P<v>[^\]]+)\]")


def check(path: Path) -> list[str]:
    problems = []
    seen: list[Version] = []
    for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        m = HEADING.match(line)
        if not m or m["v"].lower() == "unreleased":
            continue
        try:
            v = Version(m["v"])
        except InvalidVersion:
            problems.append(f"{path}:{lineno}: '{m['v']}' is not a valid version")
            continue
        if seen and v >= seen[-1]:
            problems.append(f"{path}:{lineno}: {v} is not older than the entry above ({seen[-1]})")
        seen.append(v)
    return problems


def main(argv: list[str]) -> int:
    problems = [p for name in argv for p in check(Path(name))]
    for problem in problems:
        print(problem)
    return 1 if problems else 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))

A hook that keeps generated docs in sync

The documentation hook uses pass_filenames: false because it does not check individual files: it regenerates the CLI reference from the real command tree and compares it with the committed file. Running through uv run --frozen means it uses the project's own environment and locked dependencies:

# scripts/hooks/check_cli_docs.py
"""Fail if docs/reference.md does not match what the CLI would generate now."""
from __future__ import annotations

import difflib
import sys
from pathlib import Path

from mytool.docs import render_reference   # renders help for every command as Markdown

DOC = Path("docs/reference.md")


def main() -> int:
    expected = render_reference()
    actual = DOC.read_text(encoding="utf-8") if DOC.exists() else ""
    if expected == actual:
        return 0
    diff = difflib.unified_diff(actual.splitlines(), expected.splitlines(),
                                "docs/reference.md (committed)", "generated", lineterm="", n=1)
    print("\n".join(list(diff)[:40]))
    print('\ndocs/reference.md is stale: run "uv run mytool docs > docs/reference.md"')
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

Generating reference documentation from the command tree is covered in generating man pages and docs from a CLI. The hook is what keeps it honest: changing a help string without regenerating the docs now fails on commit, with the exact command to fix it.

The hook contract

The hook contract pre-commit passes staged filenames to the hook, the hook checks or modifies them, and its exit code plus any file changes decide whether the commit proceeds. The hook contract git commit pre-commit your hook run hooks argv: staged filenames exit 0, or 1 + messages files modified? then fail A hook that fixes files should still exit non-zero, so the user reviews and re-stages the fix.

Every hook, whatever its language, follows the same contract with pre-commit:

  • Input is the list of staged files matching the hook's filters, as command-line arguments — unless pass_filenames: false. The list may be split across several invocations if it is long.
  • Exit code 0 means pass; anything else means fail.
  • Modifying files also means fail, even with exit code 0. pre-commit detects the change and stops the commit so the developer can review and stage the modification. Hooks that fix things should still exit non-zero to be explicit.
  • Output is shown only when the hook fails (or with verbose: true). Keep it short and actionable.
  • The working directory is the repository root.

UX considerations

  • Fast or not at all. Local hooks run on every commit. Anything over a second or two will be skipped with --no-verify within a week. Filter with files: so slow hooks only run when relevant files change, and move genuinely slow checks to CI.
  • Tell people the fix. The last line of a failure should be the exact command that fixes it, as the docs hook does.
  • Prefer python over system when you can. system hooks fail confusingly for a contributor who has not run uv sync. When you need the project environment, uv run --frozen makes the failure mode obvious and cheap to fix.
  • Name hooks for the problem. "CLI reference is up to date" in pre-commit's output tells a contributor what failed without opening the config.
Testing a local hook without committing Terminal output of running a single local pre-commit hook against all files to test it before relying on it. Testing a local hook without committing bash $ pre-commit run check-cli-docs --all-files CLI reference is up to date.......................................Failed docs/reference.md is stale: run "uv run mytool docs > docs/reference.md" $ pre-commit try-repo . check-cli-docs --all-files try-repo runs hooks from a working tree, so you can test hook definitions before committing them.

Testing the behaviour

Hook scripts are ordinary Python and deserve ordinary unit tests — call main() with filenames and assert on the return value and output:

# tests/test_hooks.py
from scripts.hooks.check_changelog import main


def test_valid_changelog(tmp_path, capsys):
    f = tmp_path / "CHANGELOG.md"
    f.write_text("# Changelog\n\n## [Unreleased]\n\n## [2.4.0]\n\n## [2.3.1]\n")
    assert main([str(f)]) == 0
    assert capsys.readouterr().out == ""


def test_invalid_and_misordered_versions(tmp_path, capsys):
    f = tmp_path / "CHANGELOG.md"
    f.write_text("## [2.3.1]\n\n## [2.4.0]\n\n## [two]\n")
    assert main([str(f)]) == 1
    out = capsys.readouterr().out
    assert ":3: 2.4.0 is not older" in out
    assert ":5: 'two' is not a valid version" in out

For the hook definitions themselves, pre-commit run <hook-id> --all-files runs one hook against the whole repository — the quickest way to see its real behaviour — and pre-commit try-repo . <hook-id> exercises hook definitions from the working tree before they are committed. Add pre-commit run --all-files to CI as well, so a hook that fails for everyone is caught even if a contributor skipped it locally.

Conclusion

Local hooks let a repository enforce its own rules at the cheapest possible moment — before the commit exists. Use pygrep for forbidden patterns, python with additional_dependencies for self-contained checks, and system with uv run --frozen when the check needs your project's own code. Follow the contract (filenames in, exit code out, modifications count as failure), keep hooks fast and their messages actionable, and unit-test the scripts behind them like any other code.

Frequently asked questions

Where should hook scripts live?

In a scripts/hooks/ directory that is not part of the installed package, so they never ship to users. Give the directory an __init__.py if you want to import the scripts in tests, and exclude it from the wheel in your build configuration.

Can a local hook use my CLI's own commands?

Yes, through language: system and uv run --frozen mytool .... That is often the best design: add a mytool check-docs or mytool lint-config command, and have the hook call it, so developers can run the same check by hand.

How do I skip a slow hook locally but keep it in CI?

Set the SKIP environment variable (SKIP=cli-reference-up-to-date git commit ...) for occasional skips, or give the hook stages: [manual] and run pre-commit run --hook-stage manual in CI. The second keeps commits fast by default while CI still enforces it.

How do I stop a local hook from diverging from CI?

Run the same thing in both places. The simplest arrangement is a CI step that runs pre-commit run --all-files, so every hook in the configuration — local ones included — gates merges exactly as it gates commits. For hooks that need the project environment, make sure the CI job runs uv sync first, as a developer would.

Should hooks auto-fix or only check?

Auto-fix when the fix is mechanical and unambiguous (regenerating docs, sorting a list); only check when a human must decide. Either way, the hook fails the commit so the change is reviewed.