Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ exit 0
| `pre-bash-commit-quality.sh` | PreToolUse | Bash | Secret/debug scan before `git commit` |
| `pre-config-protection.sh` | PreToolUse | Write\|Edit\|MultiEdit | Block weakening ruff/pyright config edits |
| `pre-protect-uv-lock.sh` | PreToolUse | Write\|Edit | Block direct edits to `uv.lock` |
| `pre-write-src-test-reminder.sh` | PreToolUse | Write\|Edit | Warn if `tests/<pkg>/test_<module>.py` missing for top-level `src/<pkg>/<module>.py` |
| `pre-write-doc-file-warning.sh` | PreToolUse | Write | Block `.md` files outside `docs/` |
| `pre-write-jinja-syntax.sh` | PreToolUse | Write | Validate Jinja2 syntax before writing |
| `pre-suggest-compact.sh` | PreToolUse | Edit\|Write | Suggest `/compact` every 50 operations |
Expand Down
4 changes: 2 additions & 2 deletions .claude/hooks/pre-config-protection.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#
# BLOCK conditions:
# - typeCheckingMode = "off" or "basic" (downgrade from strict / standard)
# - Core ruff rule category added to ignore list: D, E, F, I, B
# - Core ruff rule category added to ignore list: D, E, F, I, B, T20, T201
#
# WARN conditions:
# - Any edit that touches [tool.ruff.lint] select/ignore in pyproject.toml
Expand Down Expand Up @@ -67,7 +67,7 @@ if re.search(r'typeCheckingMode\s*=\s*"(off|basic)"', new_content):
ignore_match = re.search(r'ignore\s*=\s*\[([^\]]*)\]', new_content, re.DOTALL)
if ignore_match:
ignore_block = ignore_match.group(1)
for rule in ("D", "E", "F", "I", "B"):
for rule in ("D", "E", "F", "I", "B", "T20", "T201"):
if re.search(rf'["\x27]{re.escape(rule)}["\x27]', ignore_block):
print(f'BLOCK:Core ruff rule "{rule}" added to ignore list — fix code instead of suppressing rules')
sys.exit(0)
Expand Down
68 changes: 68 additions & 0 deletions .claude/hooks/pre-write-src-test-reminder.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Claude PreToolUse hook — Write|Edit
# Remind to add a pytest module when touching a top-level package source file.
#
# Scope: only paths matching src/<package>/<module>.py (exactly one directory between
# src/ and the file), excluding __init__.py. Nested layouts such as
# src/<pkg>/common/foo.py are skipped — they are often covered by shared tests
# (e.g. test_support.py).
#
# Reference : https://github.com/affaan-m/everything-claude-code/blob/main/hooks/README.md
# (recipe: Require test files alongside new source files — pytest layout)
# Exits : 0 always (non-blocking; warnings on stderr only)

set -uo pipefail

INPUT=$(cat)

FILE_PATH=$(printf '%s' "$INPUT" | python3 -c '
import json, sys

data = json.load(sys.stdin)
print(data.get("tool_input", {}).get("file_path", ""))
') || {
echo "$INPUT"
exit 0
}

[[ "$FILE_PATH" == *.py ]] || {
echo "$INPUT"
exit 0
}

# Normalise for matching (JSON paths may use backslashes on Windows clients).
FP="${FILE_PATH//\\//}"
FP="${FP#./}"

if [[ ! "$FP" =~ ^(.*/)?src/([^/]+)/([^/]+)\.py$ ]]; then
echo "$INPUT"
exit 0
fi

PKG="${BASH_REMATCH[2]}"
MOD="${BASH_REMATCH[3]}"

if [[ "$MOD" == "__init__" ]]; then
echo "$INPUT"
exit 0
fi

EXPECTED_TEST="tests/${PKG}/test_${MOD}.py"

if [[ -f "$EXPECTED_TEST" ]]; then
echo "$INPUT"
exit 0
fi

echo "┌─ Test module reminder" >&2
echo "│" >&2
echo "│ Source : $FP" >&2
echo "│ No file: $EXPECTED_TEST" >&2
echo "│" >&2
echo "│ Add tests under tests/<package>/ (pytest: test_<module>.py) or extend an" >&2
echo "│ existing test module that imports this code." >&2
echo "│ Nested src paths (e.g. src/<pkg>/common/…) are not checked." >&2
echo "└─" >&2

echo "$INPUT"
exit 0
10 changes: 10 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@
],
"description": "Block direct edits to uv.lock — must be regenerated via uv lock or just update"
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/pre-write-src-test-reminder.sh"
}
],
"description": "Remind to add tests/<pkg>/test_<module>.py for top-level src/<pkg>/<module>.py"
},
{
"matcher": "Write",
"hooks": [
Expand Down
30 changes: 16 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,22 @@ target-version = "py311"

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"RUF", # ruff-specific rules
"D", # pydocstyle — enforce Google-style docstrings
"C90", # McCabe complexity
"PERF", # perflint — catch performance anti-patterns
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"RUF", # ruff-specific rules
"D", # pydocstyle — enforce Google-style docstrings
"C90", # McCabe complexity
"PERF", # perflint — catch performance anti-patterns
"T20", # flake8-print — discourage print() in non-test code
]

ignore = [
"E501", # line too long (handled by formatter)
"E501", # line too long (handled by formatter)
]

[tool.ruff.lint.pydocstyle]
Expand All @@ -52,8 +54,8 @@ convention = "google"
max-complexity = 10

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D"] # test functions don't require docstrings
"scripts/**" = ["D"] # utility scripts don't require docstrings
"tests/**" = ["D", "T20"] # test functions don't require docstrings; print OK in tests
"scripts/**" = ["D", "T20"] # utility scripts may use print

[tool.pytest.ini_options]
minversion = "8.0"
Expand Down
1 change: 1 addition & 0 deletions template/.claude/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ exit 0
| `pre-bash-commit-quality.sh` | PreToolUse | Bash | Secret/debug scan before commit |
| `pre-config-protection.sh` | PreToolUse | Write\|Edit\|MultiEdit | Block weakening ruff/pyright config |
| `pre-protect-uv-lock.sh` | PreToolUse | Write\|Edit | Block direct edits to `uv.lock` |
| `pre-write-src-test-reminder.sh` | PreToolUse | Write\|Edit | Warn if `tests/<pkg>/test_<module>.py` missing for top-level `src/<pkg>/<module>.py` |

## Adding a new hook

Expand Down
4 changes: 2 additions & 2 deletions template/.claude/hooks/pre-config-protection.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#
# BLOCK conditions:
# - typeCheckingMode = "off" or "basic" (downgrade from strict / standard)
# - Core ruff rule category added to ignore list: D, E, F, I, B
# - Core ruff rule category added to ignore list: D, E, F, I, B, T20, T201
#
# WARN conditions:
# - Any edit that touches [tool.ruff.lint] select/ignore in pyproject.toml
Expand Down Expand Up @@ -67,7 +67,7 @@ if re.search(r'typeCheckingMode\s*=\s*"(off|basic)"', new_content):
ignore_match = re.search(r'ignore\s*=\s*\[([^\]]*)\]', new_content, re.DOTALL)
if ignore_match:
ignore_block = ignore_match.group(1)
for rule in ("D", "E", "F", "I", "B"):
for rule in ("D", "E", "F", "I", "B", "T20", "T201"):
if re.search(rf'["\x27]{re.escape(rule)}["\x27]', ignore_block):
print(f'BLOCK:Core ruff rule "{rule}" added to ignore list — fix code instead of suppressing rules')
sys.exit(0)
Expand Down
68 changes: 68 additions & 0 deletions template/.claude/hooks/pre-write-src-test-reminder.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Claude PreToolUse hook — Write|Edit
# Remind to add a pytest module when touching a top-level package source file.
#
# Scope: only paths matching src/<package>/<module>.py (exactly one directory between
# src/ and the file), excluding __init__.py. Nested layouts such as
# src/<pkg>/common/foo.py are skipped — they are often covered by shared tests
# (e.g. test_support.py).
#
# Reference : https://github.com/affaan-m/everything-claude-code/blob/main/hooks/README.md
# (recipe: Require test files alongside new source files — pytest layout)
# Exits : 0 always (non-blocking; warnings on stderr only)

set -uo pipefail

INPUT=$(cat)

FILE_PATH=$(printf '%s' "$INPUT" | python3 -c '
import json, sys

data = json.load(sys.stdin)
print(data.get("tool_input", {}).get("file_path", ""))
') || {
echo "$INPUT"
exit 0
}

[[ "$FILE_PATH" == *.py ]] || {
echo "$INPUT"
exit 0
}

# Normalise for matching (JSON paths may use backslashes on Windows clients).
FP="${FILE_PATH//\\//}"
FP="${FP#./}"

if [[ ! "$FP" =~ ^(.*/)?src/([^/]+)/([^/]+)\.py$ ]]; then
echo "$INPUT"
exit 0
fi

PKG="${BASH_REMATCH[2]}"
MOD="${BASH_REMATCH[3]}"

if [[ "$MOD" == "__init__" ]]; then
echo "$INPUT"
exit 0
fi

EXPECTED_TEST="tests/${PKG}/test_${MOD}.py"

if [[ -f "$EXPECTED_TEST" ]]; then
echo "$INPUT"
exit 0
fi

echo "┌─ Test module reminder" >&2
echo "│" >&2
echo "│ Source : $FP" >&2
echo "│ No file: $EXPECTED_TEST" >&2
echo "│" >&2
echo "│ Add tests under tests/<package>/ (pytest: test_<module>.py) or extend an" >&2
echo "│ existing test module that imports this code." >&2
echo "│ Nested src paths (e.g. src/<pkg>/common/…) are not checked." >&2
echo "└─" >&2

echo "$INPUT"
exit 0
20 changes: 16 additions & 4 deletions template/.claude/rules/python/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
### What the hook checks

1. **ruff check** — all active rule sets including `D` (docstrings), `C90` (complexity),
`PERF` (performance anti-patterns).
`PERF` (performance anti-patterns), `T20` / `T201` (no `print()` in app code).
2. **basedpyright** — type correctness in strict mode.

Example output:
Expand Down Expand Up @@ -42,9 +42,10 @@ Fix all violations before moving to the next file.

The `pre-bash-block-no-verify.sh` hook prevents `git commit --no-verify`.

## `print()` warning
## `print()` enforcement

`print()` in `src/` is a ruff violation (`T201`). Use structlog:
Ruff includes **`T20`** (flake8-print) in `[tool.ruff.lint] select`. `print()` in
application code is reported as **`T201`**. Use structlog:

```python
# Wrong
Expand All @@ -55,7 +56,18 @@ log = structlog.get_logger()
log.info("processing_order", order_id=order_id)
```

`print()` is permitted in `scripts/` and test files.
`print()` is permitted in `scripts/`, `tests/**`, and `src/**/bump_version.py` (per-file
ignores in `pyproject.toml`).

## Top-level module test reminder (PreToolUse)

| Hook | Trigger | What it does |
|------|---------|----------------|
| `pre-write-src-test-reminder.sh` | `Write` or `Edit` on `src/<pkg>/<module>.py` | Warns if `tests/<pkg>/test_<module>.py` is missing |

Only **top-level package modules** are checked (`src/<pkg>/<name>.py`, excluding
`__init__.py`). Nested packages (for example `src/<pkg>/common/foo.py`) are skipped,
since those modules are often covered by shared test modules such as `test_support.py`.

## Type-checking configuration

Expand Down
10 changes: 10 additions & 0 deletions template/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@
}
],
"description": "Block direct edits to uv.lock — must be regenerated via uv lock or just update"
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/pre-write-src-test-reminder.sh"
}
],
"description": "Remind to add tests/<pkg>/test_<module>.py for top-level src/<pkg>/<module>.py"
}
],
"PostToolUse": [
Expand Down
5 changes: 4 additions & 1 deletion template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ select = [
"D", # pydocstyle — enforce Google-style docstrings on public symbols
"C90", # McCabe complexity — flag functions above max-complexity
"PERF", # perflint — catch performance anti-patterns
"T20", # flake8-print — no print() in application code (use structlog)
]

ignore = [
Expand All @@ -120,7 +121,9 @@ convention = "google"
max-complexity = 10

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["ARG", "D"] # tests don't require docstrings
"tests/**" = ["ARG", "D", "T20"] # tests don't require docstrings; print() OK in tests
"scripts/**" = ["T20"] # CLI-style scripts may use print
"src/**/bump_version.py" = ["T20"] # emits version on stdout for tooling

[tool.ruff.lint.isort]
known-first-party = ["{{ package_name }}"]
Expand Down
15 changes: 15 additions & 0 deletions tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,21 @@ def test_generated_pyproject_basedpyright_standard_mode(tmp_path: Path) -> None:
assert "reportMissingImports = true" in raw


def test_generated_pyproject_ruff_includes_print_rules(tmp_path: Path) -> None:
"""Generated projects should enable Ruff T20 (flake8-print) with sensible per-file ignores."""
test_dir = tmp_path / "ruff_t20"
copy_with_data(test_dir, {"project_name": "Ruff T20", "include_docs": False})
data = tomllib.loads((test_dir / "pyproject.toml").read_text(encoding="utf-8"))
ruff_lint = cast(Mapping[str, object], cast(Mapping[str, object], data["tool"])["ruff"])
lint = cast(Mapping[str, object], ruff_lint["lint"])
select = cast(list[str], lint["select"])
assert "T20" in select
per_file = cast(Mapping[str, list[str]], lint["per-file-ignores"])
assert "T20" in per_file["tests/**"]
assert "T20" in per_file["scripts/**"]
assert "T20" in per_file["src/**/bump_version.py"]


def test_generated_pre_commit_includes_detect_secrets(tmp_path: Path) -> None:
"""Pre-commit config in generated projects should run detect-secrets with a baseline."""
test_dir = tmp_path / "secrets_hook"
Expand Down
Loading