Python Pre-Commit Hooks Practice Problems & Exercises
Practice: Pre-Commit Hooks
← Back to lessonEasy
Task: Fill in config_yaml with a valid .pre-commit-config.yaml containing the four hooks described.
Solution:
config_yaml = """
# .pre-commit-config.yaml
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
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
language_version: python3
"""
print(config_yaml.strip())
print("pre-commit config written — 3 hooks configured")
# Install hooks: pre-commit install
# Run on all files: pre-commit run --all-files
# Update revisions: pre-commit autoupdate
# Write a .pre-commit-config.yaml that:
# 1. Uses pre-commit-hooks repo (rev v4.6.0)
# - trailing-whitespace hook
# - end-of-file-fixer hook
# - check-yaml hook
# 2. Formats with black (rev 24.3.0)
config_yaml = """
# .pre-commit-config.yaml
# fill in here
"""
print(config_yaml)
print("pre-commit config written — 3 hooks configured")
Expected Output
pre-commit config written — 3 hooks configuredHints
Hint 1: The top-level key is `repos:`, each entry has `repo:`, `rev:`, and `hooks:`.
Hint 2: `pre-commit-hooks` is the standard repository for common lightweight checks.
Hint 3: `rev:` should be a tagged version string like `v4.6.0` — not `main` or `master`.
Task: Match each pre-commit command to its correct description.
Solution:
commands = {
"pre-commit install": "D: Installs the pre-commit script into .git/hooks/pre-commit",
"pre-commit run --all-files": "E: Runs all configured hooks against every file in the repo",
"pre-commit run black": "B: Runs only the black hook against staged files",
"pre-commit autoupdate": "A: Updates hook versions in config to their latest tagged releases",
"pre-commit uninstall": "C: Removes the pre-commit hook script from .git/hooks/",
}
for cmd, desc in commands.items():
print(f" {cmd!r:40s} -> {desc}")
print("All commands explained correctly")
# Match each command to its description
commands = {
"pre-commit install": None,
"pre-commit run --all-files": None,
"pre-commit run black": None,
"pre-commit autoupdate": None,
"pre-commit uninstall": None,
}
descriptions = [
"A: Updates hook versions in config to their latest tagged releases",
"B: Runs only the black hook against staged files",
"C: Removes the pre-commit hook script from .git/hooks/",
"D: Installs the pre-commit script into .git/hooks/pre-commit",
"E: Runs all configured hooks against every file in the repo",
]
# Fill in each command with the matching letter
Expected Output
All commands explained correctlyHints
Hint 1: `pre-commit install` writes a `.git/hooks/pre-commit` script that runs automatically on `git commit`.
Hint 2: `pre-commit run --all-files` runs all hooks against every file — useful after adding a new hook.
Hint 3: `git commit --no-verify` bypasses hooks — use sparingly and only in emergencies.
Task: Fill in the config snippet. For the commit-msg and pytest hooks, add the correct stages: entry.
Solution:
config_snippet = """
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
# no stages: key means default = commit stage
- repo: local
hooks:
- id: check-commit-message-style
name: Check commit message starts with capital letter
language: system
entry: bash -c 'head -1 "$1" | grep -qP "^[A-Z]" || (echo "Commit message must start with capital letter" && exit 1)'
stages: [commit-msg]
- repo: local
hooks:
- id: run-pytest
name: Run pytest before push
language: system
entry: pytest
stages: [pre-push]
pass_filenames: false
"""
print(config_snippet.strip())
print("Stage config written for 3 hooks")
# Installation for non-default stages:
# pre-commit install --hook-type commit-msg
# pre-commit install --hook-type pre-push
# Write a .pre-commit-config.yaml config snippet that:
# 1. Runs trailing-whitespace at the default commit stage
# 2. Runs a commit-msg hook (check-commit-message-style — local repo)
# that enforces: message must start with a capital letter
# 3. Runs pytest only at the pre-push stage (too slow for every commit)
config_snippet = """
repos:
# fill in here
"""
print(config_snippet)
print("Stage config written for 3 hooks")
Expected Output
Stage config written for 3 hooksHints
Hint 1: Add `stages: [commit-msg]` to a hook entry to run it only during `git commit --msg`.
Hint 2: `stages: [pre-push]` runs the hook only when `git push` is executed.
Hint 3: You need `pre-commit install --hook-type commit-msg` to activate commit-msg stage hooks.
Task: Fill in the scenarios dict with the correct action for each situation.
Solution:
scenarios = {
"black modifies a file during pre-commit": (
"1. The hook fails (non-zero exit) and aborts the commit. "
"2. black has already written the formatted file to disk. "
"3. Run: git add <file> && git commit -m 'msg' to retry."
),
"flake8 reports an error during pre-commit": (
"1. Hook fails and commit is aborted. "
"2. Fix the reported violations in your editor. "
"3. git add the fixed file, then re-run git commit."
),
"skip the 'mypy' hook for one commit only": (
"SKIP=mypy git commit -m 'your message' "
"-- use sparingly, e.g. WIP commits where types aren't finalised yet."
),
"permanently disable a hook without removing it from config": (
"Add 'stages: []' to the hook entry — it will never match any stage. "
"Or comment it out with '#'. "
"Do NOT use git commit --no-verify — that bypasses ALL hooks."
),
}
for scenario, action in scenarios.items():
print(f"Scenario: {scenario}")
print(f"Action: {action}")
print()
print("Hook behaviour explained correctly")
# Explain the pre-commit failure workflow
scenarios = {
"black modifies a file during pre-commit": None,
"flake8 reports an error during pre-commit": None,
"skip the 'mypy' hook for one commit only": None,
"permanently disable a hook without removing it from config": None,
}
for scenario, action in scenarios.items():
print(f"Scenario: {scenario}")
print(f"Action: {action}")
print()
Expected Output
Hook behaviour explained correctlyHints
Hint 1: When a hook fails, the commit is aborted and you must fix the issues then re-stage.
Hint 2: Set `SKIP=hook-id git commit -m "msg"` to skip a specific hook for one commit.
Hint 3: Some hooks (like black) modify files — you must `git add` the changes before recommitting.
Medium
Task: Implement check_file to open the file, scan each line for a case-insensitive TODO pattern, and return a list of formatted violation strings.
Solution:
import sys
import re
def check_file(filepath):
violations = []
try:
with open(filepath, encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
if re.search(r"#\s*todo", line, re.IGNORECASE):
violations.append(
f"FAIL: {filepath}: TODO found on line {lineno}: {line.rstrip()}"
)
except (OSError, UnicodeDecodeError):
pass
return violations
def main():
files = sys.argv[1:]
all_violations = []
for f in files:
all_violations.extend(check_file(f))
if all_violations:
for v in all_violations:
print(v)
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
# .pre-commit-config.yaml entry:
# - repo: local
# hooks:
# - id: no-todos
# name: No TODO comments in committed code
# language: python
# entry: python hooks/check_no_todos.py
# types: [python]
print("Local hook script written and validated")
# Write a local pre-commit hook script (check_no_todos.py)
# that fails if any staged Python file contains a TODO comment
# (case-insensitive, e.g. # TODO, # todo, # Todo)
# The script receives filenames as sys.argv[1:]
# Print: "FAIL: <filename>: TODO found on line <n>: <line>"
# Exit 1 if any violation found, 0 if clean
import sys
def check_file(filepath):
violations = []
# open file, check each line for TODO
return violations
def main():
files = sys.argv[1:]
all_violations = []
for f in files:
all_violations.extend(check_file(f))
if all_violations:
for v in all_violations:
print(v)
sys.exit(1)
sys.exit(0)
if __name__ == '__main__':
main()
Expected Output
Local hook script written and validatedHints
Hint 1: Local hooks use `repo: local` with `language: python` and point `entry:` to a script in your repo.
Hint 2: The script receives filenames as arguments — iterate with `sys.argv[1:]`.
Hint 3: Exit with code 1 (and print an error) to fail the hook; exit 0 to pass.
Task: Fill in the complete config. Group hooks under the correct repo: entries. Add args: [--fix] to the ruff lint hook.
Solution:
full_config = """
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.1
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: mypy
name: mypy type check
language: system
entry: mypy
types: [python]
pass_filenames: false
args: [src]
- repo: local
hooks:
- id: pytest
name: Run test suite
language: system
entry: pytest
pass_filenames: false
stages: [pre-push]
"""
print(full_config.strip())
print("Full config written: 6 hooks across 4 repos")
# Write a complete .pre-commit-config.yaml with these 6 hooks in order:
# 1. trailing-whitespace (pre-commit-hooks v4.6.0)
# 2. end-of-file-fixer (pre-commit-hooks v4.6.0)
# 3. ruff (astral-sh/ruff-pre-commit v0.4.1) -- lint + fix
# 4. ruff-format (astral-sh/ruff-pre-commit v0.4.1) -- format
# 5. mypy (local, language=system, entry=mypy, types=[python], pass_filenames=false)
# 6. pytest (local, language=system, stages=[pre-push], pass_filenames=false)
full_config = """
# .pre-commit-config.yaml
repos:
# fill in
"""
print(full_config)
print("Full config written: 6 hooks across 4 repos")
Expected Output
Full config written: 6 hooks across 4 reposHints
Hint 1: Order hooks from fast to slow — trivial checks first (trailing-whitespace), heavy last (mypy, pytest).
Hint 2: Use `types: [python]` to restrict a hook to only Python files.
Hint 3: `pass_filenames: false` is needed for tools like pytest that should not receive filenames as args.
Task: Fill in the GitLab CI job with the correct configuration including cache, environment variables, and script steps.
Solution:
gitlab_ci_job = """
# .gitlab-ci.yml excerpt
pre-commit:
stage: lint
image: python:3.11-slim
variables:
PRE_COMMIT_HOME: "$CI_PROJECT_DIR/.pre-commit-cache"
cache:
key: pre-commit-$CI_COMMIT_REF_SLUG
paths:
- .pre-commit-cache/
before_script:
- pip install pre-commit
script:
# Run only on changed files between base and HEAD
- pre-commit run --from-ref $CI_MERGE_REQUEST_DIFF_BASE_SHA --to-ref HEAD
# Fallback for non-MR pipelines (e.g. direct push to main)
- |
if [ -z "$CI_MERGE_REQUEST_DIFF_BASE_SHA" ]; then
pre-commit run --all-files
fi
rules:
- when: always
"""
print(gitlab_ci_job.strip())
print("CI configuration written — pre-commit runs on every push")
# Write a GitLab CI job that:
# 1. Installs pre-commit
# 2. Caches pre-commit environments at $PRE_COMMIT_HOME
# 3. Runs pre-commit on all changed files (not all files)
# 4. Runs on every push to any branch
gitlab_ci_job = """
# .gitlab-ci.yml excerpt
pre-commit:
# fill in
"""
print(gitlab_ci_job)
print("CI configuration written — pre-commit runs on every push")
Expected Output
CI configuration written — pre-commit runs on every pushHints
Hint 1: In CI there is no interactive git commit — run `pre-commit run --all-files` directly.
Hint 2: Cache the pre-commit environments with `PRE_COMMIT_HOME` to avoid re-downloading on every pipeline run.
Hint 3: Use `--from-ref` and `--to-ref` to only check changed files in a PR.
Task: Fill in all five answers about version pinning strategy for pre-commit hooks.
Solution:
questions = {
"why_use_tags_not_branches": (
"Branches (main, master) can change at any commit — a hook that worked yesterday "
"may break today. Tags are immutable, so pinning ensures reproducible builds across "
"all developer machines and CI runs."
),
"command_to_update_all_hooks": (
"pre-commit autoupdate -- updates every repo's rev: to the latest tagged release "
"and writes the changes back to .pre-commit-config.yaml."
),
"how_to_update_single_hook_repo": (
"pre-commit autoupdate --repo https://github.com/psf/black "
"-- only updates the black repo entry."
),
"risk_of_not_pinning": (
"Pinning to a branch means a hook maintainer's new commit could silently introduce "
"a breaking change or security issue. Unpinned hooks also create non-reproducible "
"environments — different developers get different hook versions."
),
"recommended_update_frequency": (
"Monthly or when security advisories appear. Create a dedicated MR/PR for autoupdate "
"changes so they are reviewed and tested in CI before merging."
),
}
for q, a in questions.items():
print(f"Q: {q}")
print(f"A: {a}")
print()
print("Version pinning strategy explained")
# Answer these version-pinning questions as a dict
questions = {
"why_use_tags_not_branches": None,
"command_to_update_all_hooks": None,
"how_to_update_single_hook_repo": None,
"risk_of_not_pinning": None,
"recommended_update_frequency": None,
}
for q, a in questions.items():
print(f"Q: {q}")
print(f"A: {a}")
print()
Expected Output
Version pinning strategy explainedHints
Hint 1: `pre-commit autoupdate` updates all `rev:` values to the latest tagged releases.
Hint 2: Always pin to exact tags (e.g., `v4.6.0`) not branches — branches change and break reproducibility.
Hint 3: Run autoupdate in a dedicated branch/MR so the diff is reviewed before merging.
Hard
Task: Fill in the secrets_hook_config with the correct repo URL and latest rev for detect-secrets. Then verify the simulation finds exactly 2 secrets in sample_code.
Solution:
import re
secrets_hook_config = """
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: |
(?x)^(
tests/fixtures/.*|
docs/.*
)$
"""
SUSPICIOUS_PATTERNS = [
r'password\s*=\s*["\'][^"\']+["\']',
r'api[_-]?key\s*=\s*["\'][^"\']+["\']',
r'secret\s*=\s*["\'][^"\']+["\']',
r'token\s*=\s*["\'][^"\']+["\']',
]
sample_code = '''
DATABASE_URL = "postgresql://user:password123@localhost/db"
API_KEY = "sk-your-api-key-here"
DEBUG = True
HOST = "localhost"
'''
def scan_for_secrets(code):
found = []
for i, line in enumerate(code.splitlines(), 1):
for pattern in SUSPICIOUS_PATTERNS:
if re.search(pattern, line, re.IGNORECASE):
found.append((i, line.strip()))
break # one report per line
return found
secrets = scan_for_secrets(sample_code)
print(f"Secrets hook configured — {len(secrets)} potential secrets detected in baseline")
for lineno, line in secrets:
print(f" Line {lineno}: {line[:60]}...")
# Setup workflow:
# 1. detect-secrets scan > .secrets.baseline
# 2. git add .secrets.baseline
# 3. git commit -m "Add secrets baseline"
# 4. pre-commit install
# Future commits: detect-secrets audits against baseline, alerts only on NEW secrets
print(secrets_hook_config.strip())
# Write a .pre-commit-config.yaml entry for detect-secrets
# AND simulate what a .secrets.baseline file looks like
secrets_hook_config = """
- repo: ???
rev: ???
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
"""
# Simulate detecting secrets in a file
import re
SUSPICIOUS_PATTERNS = [
r'passwords*=s*["'][^"']+["']',
r'api[_-]?keys*=s*["'][^"']+["']',
r'secrets*=s*["'][^"']+["']',
r'tokens*=s*["'][^"']+["']',
]
sample_code = '''
DATABASE_URL = "postgresql://user:password123@localhost/db"
API_KEY = "sk-your-api-key-here"
DEBUG = True
HOST = "localhost"
'''
def scan_for_secrets(code):
found = []
for i, line in enumerate(code.splitlines(), 1):
for pattern in SUSPICIOUS_PATTERNS:
if re.search(pattern, line, re.IGNORECASE):
found.append((i, line.strip()))
return found
secrets = scan_for_secrets(sample_code)
print(f"Secrets hook configured — {len(secrets)} potential secrets detected in baseline")
for lineno, line in secrets:
print(f" Line {lineno}: {line[:60]}...")
Expected Output
Secrets hook configured — 2 potential secrets detected in baselineHints
Hint 1: `detect-secrets` by Yelp scans for passwords, API keys, and tokens using heuristic patterns.
Hint 2: Run `detect-secrets scan > .secrets.baseline` to create the initial baseline file.
Hint 3: Commit the baseline — it tracks known false positives so the hook only alerts on new secrets.
Task: Fill in the hook config with language, entry, and additional_dependencies. Complete the main function in the script to exit 1 with an error if [tool.ruff] is missing, exit 0 if present.
Solution:
hook_config = """
- repo: local
hooks:
- id: check-pyproject
name: Validate pyproject.toml has ruff config
language: python
entry: python hooks/check_pyproject.py
pass_filenames: false
additional_dependencies:
- tomli==2.0.1
"""
check_pyproject_script = '''
import sys
try:
import tomllib # Python 3.11+ stdlib
except ImportError:
import tomli as tomllib # fallback for older Python
def main():
try:
with open("pyproject.toml", "rb") as f:
config = tomllib.load(f)
except FileNotFoundError:
print("ERROR: pyproject.toml not found")
sys.exit(1)
tool = config.get("tool", {})
if "ruff" not in tool:
print("ERROR: pyproject.toml is missing [tool.ruff] section")
print(" Add ruff configuration to enforce code quality standards.")
sys.exit(1)
print("OK: pyproject.toml contains [tool.ruff] configuration")
sys.exit(0)
if __name__ == "__main__":
main()
'''
print("Hook config:")
print(hook_config.strip())
print()
print("Script:")
print(check_pyproject_script.strip())
print()
print("Hook with environment written — uses additional_dependencies")
# Write a local hook that:
# 1. Uses language: python
# 2. Installs 'tomli==2.0.1' as an additional dependency
# 3. Reads pyproject.toml and validates it has a [tool.ruff] section
# Script: check_pyproject.py
hook_config = """
- repo: local
hooks:
- id: check-pyproject
name: Validate pyproject.toml has ruff config
# fill in language, entry, additional_dependencies
"""
check_pyproject_script = '''
import sys
import tomli # installed via additional_dependencies
def main():
try:
with open("pyproject.toml", "rb") as f:
config = tomli.load(f)
except FileNotFoundError:
print("ERROR: pyproject.toml not found")
sys.exit(1)
# Check for [tool.ruff] section
tool = config.get("tool", {})
# TODO: check and exit appropriately
if __name__ == "__main__":
main()
'''
print("Hook config:")
print(hook_config)
print("Script:")
print(check_pyproject_script)
print("Hook with environment written — uses additional_dependencies")
Expected Output
Hook with environment written — uses additional_dependenciesHints
Hint 1: Use `language: python` with `additional_dependencies: [packagename==version]` to install packages into the hook's isolated environment.
Hint 2: This is the correct way to use third-party packages (like `requests` or `pydantic`) inside a local hook.
Hint 3: The environment is cached by pre-commit under `PRE_COMMIT_HOME` — it is created once and reused.
Task: Fill in all six strategy entries with concrete, actionable guidance a team lead would give to new engineers.
Solution:
strategy = {
"developer_setup_command": (
"Add to Makefile: 'make dev-install' that runs: "
"pip install -r requirements-dev.txt && pre-commit install && pre-commit install --hook-type commit-msg. "
"Document in README: 'After cloning, run: make dev-install'"
),
"make_hooks_mandatory": (
"You cannot truly force local hook installation on developers. Instead: "
"(1) Document clearly in CONTRIBUTING.md and README. "
"(2) Add a CI job (see below) that enforces the same checks server-side. "
"(3) Make PRs unmergeable without the CI job passing."
),
"handle_ci_enforcement": (
"Add a GitLab CI or GitHub Actions job: "
"'pre-commit run --all-files' on every push. "
"Set it as a required status check — merging is blocked if hooks fail. "
"This catches any developer who skipped 'pre-commit install'."
),
"handle_emergency_bypass": (
"Allowed emergency bypass: SKIP=hook-id git commit (skips one hook). "
"Last resort: git commit --no-verify (bypasses all). "
"Policy: --no-verify use must be justified in the commit message and reviewed in MR. "
"CI still enforces — bypassing locally does not bypass CI."
),
"keeping_hooks_up_to_date": (
"Monthly: create a branch, run 'pre-commit autoupdate', open a PR. "
"Review diff for breaking changes before merging. "
"After merging: notify team to run 'pre-commit install' again (same command refreshes). "
"Run 'pre-commit gc' to clean up stale cached environments."
),
"onboarding_documentation": (
"CONTRIBUTING.md must include: "
"(1) One-command setup: 'make dev-install'. "
"(2) What each hook checks (brief list). "
"(3) How to skip one hook: SKIP=id git commit. "
"(4) Where to find CI results if commit passes locally but fails remotely. "
"(5) Who to contact to add or change a hook."
),
}
print("=== Team Pre-Commit Strategy ===")
for step, guidance in strategy.items():
print(f"\n[{step}]")
print(f" {guidance}")
print("\nFull pre-commit strategy documented for team onboarding")
# Design a complete team pre-commit onboarding strategy.
# Fill in the strategy dict with concrete, actionable guidance.
strategy = {
"developer_setup_command": None,
"make_hooks_mandatory": None,
"handle_ci_enforcement": None,
"handle_emergency_bypass": None,
"keeping_hooks_up_to_date": None,
"onboarding_documentation": None,
}
print("=== Team Pre-Commit Strategy ===")
for step, guidance in strategy.items():
print(f"
[{step}]")
print(f" {guidance}")
print("
Full pre-commit strategy documented for team onboarding")
Expected Output
Full pre-commit strategy documented for team onboardingHints
Hint 1: A CONTRIBUTING.md entry + `pre-commit install` in the dev setup script ensures all developers install hooks.
Hint 2: Add a CI job that runs `pre-commit run --all-files` so hooks are enforced even if bypassed locally.
Hint 3: Use `pre-commit gc` to clean up unused cached environments when hook versions change.
