Skip to content

Commit 5aa24fe

Browse files
committed
feat: allow mypy to be used in a Stop hook
1 parent 85a9b9a commit 5aa24fe

5 files changed

Lines changed: 161 additions & 7 deletions

File tree

.claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"type": "command",
2222
"command": "poetry run python-claude ruff check"
2323
},
24+
{
25+
"type": "command",
26+
"command": "poetry run python-claude mypy"
27+
},
2428
{
2529
"type": "command",
2630
"command": "poetry run python-claude pytest"

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ This package provides hooks that can be used with Claude Code's hook system.
3131

3232
- `edited` - Tracks edited Python files for deferred processing
3333
- `git status` - Shows git status
34-
- `mypy` - Runs mypy type checking on edited files
34+
- `mypy` - Runs mypy type checking on edited files when used as a PostToolUse hook or all files when used as a Stop hook
3535
- `pytest` - Runs pytest
3636
- `ruff check` - Runs ruff check on collected files with auto-fix
3737
- `ruff format` - Runs ruff format on edited files
@@ -65,6 +65,10 @@ Add hooks to your Claude Code settings.json:
6565
"type": "command",
6666
"command": "poetry run python-claude ruff check"
6767
},
68+
{
69+
"type": "command",
70+
"command": "poetry run python-claude mypy"
71+
},
6872
{
6973
"type": "command",
7074
"command": "poetry run python-claude pytest"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-claude"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
description = "Python hooks for Claude Code"
55
authors = [{name = "CVector", email = "support@cvector.com"}]
66
readme = "README.md"

src/python_claude/hooks/mypy_hook.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,28 @@ def __init__(self, hook_input: HookInput | None = None) -> None:
1515
super().__init__(hook_input)
1616

1717
def run(self) -> int:
18-
"""Run mypy on the edited file if it's a Python file."""
18+
"""Run mypy on the edited file or entire project.
19+
20+
If file_path is provided and is a Python file, run mypy on that file.
21+
If no file_path is provided (e.g., Stop hook), run mypy on entire project.
22+
"""
1923
file_path = self.input.file_path
20-
if not file_path or not self.is_python_file(file_path):
21-
return 0
2224

23-
self.log(file_path)
25+
# Determine what to type check
26+
if file_path:
27+
# File path provided - check if it's a Python file
28+
if not self.is_python_file(file_path):
29+
return 0
30+
mypy_target = file_path
31+
else:
32+
# No file path (Stop hook) - check entire project
33+
mypy_target = "."
34+
35+
self.log(mypy_target)
2436

2537
# mypy writes errors to stdout, but only stderr is fed back to Claude
2638
result = subprocess.run(
27-
["poetry", "run", "mypy", file_path],
39+
["poetry", "run", "mypy", mypy_target],
2840
cwd=self.project_dir,
2941
stdout=sys.stderr, # Redirect stdout to stderr for Claude
3042
)

tests/test_mypy_hook.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Tests for MypyHook."""
2+
3+
import os
4+
from pathlib import Path
5+
from unittest.mock import MagicMock, patch
6+
7+
from python_claude.hooks.base import HookInput
8+
from python_claude.hooks.mypy_hook import MypyHook
9+
10+
11+
class TestMypyHook:
12+
def test_python_file_success(self, tmp_path: Path) -> None:
13+
"""Test mypy succeeds on a Python file."""
14+
hook_input = HookInput(
15+
session_id=None,
16+
tool_input={"file_path": "/path/to/file.py"},
17+
raw={},
18+
)
19+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
20+
hook = MypyHook(hook_input)
21+
mock_result = MagicMock()
22+
mock_result.returncode = 0
23+
with patch("subprocess.run", return_value=mock_result) as mock_run:
24+
exit_code = hook.run()
25+
assert exit_code == 0
26+
mock_run.assert_called_once()
27+
# Verify it ran mypy on the specific file
28+
call_args = mock_run.call_args
29+
assert call_args[0][0] == ["poetry", "run", "mypy", "/path/to/file.py"]
30+
31+
def test_python_file_type_errors(self, tmp_path: Path) -> None:
32+
"""Test mypy type errors (exit 1) are transformed to exit code 2."""
33+
hook_input = HookInput(
34+
session_id=None,
35+
tool_input={"file_path": "/path/to/file.py"},
36+
raw={},
37+
)
38+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
39+
hook = MypyHook(hook_input)
40+
mock_result = MagicMock()
41+
mock_result.returncode = 1
42+
with patch("subprocess.run", return_value=mock_result):
43+
exit_code = hook.run()
44+
assert exit_code == 2
45+
46+
def test_python_file_other_error(self, tmp_path: Path) -> None:
47+
"""Test other mypy errors are passed through unchanged."""
48+
hook_input = HookInput(
49+
session_id=None,
50+
tool_input={"file_path": "/path/to/file.py"},
51+
raw={},
52+
)
53+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
54+
hook = MypyHook(hook_input)
55+
mock_result = MagicMock()
56+
mock_result.returncode = 3
57+
with patch("subprocess.run", return_value=mock_result):
58+
exit_code = hook.run()
59+
assert exit_code == 3
60+
61+
def test_non_python_file(self, tmp_path: Path) -> None:
62+
"""Test non-Python files are skipped."""
63+
hook_input = HookInput(
64+
session_id=None,
65+
tool_input={"file_path": "/path/to/file.txt"},
66+
raw={},
67+
)
68+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
69+
hook = MypyHook(hook_input)
70+
with patch("subprocess.run") as mock_run:
71+
exit_code = hook.run()
72+
assert exit_code == 0
73+
# Verify subprocess was not called
74+
mock_run.assert_not_called()
75+
76+
def test_stop_hook_no_file_path_success(self, tmp_path: Path) -> None:
77+
"""Test Stop hook (no file_path) runs mypy on entire project."""
78+
hook_input = HookInput(
79+
session_id="abc123",
80+
tool_input={},
81+
raw={
82+
"session_id": "abc123",
83+
"hook_event_name": "Stop",
84+
"stop_hook_active": True,
85+
},
86+
)
87+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
88+
hook = MypyHook(hook_input)
89+
mock_result = MagicMock()
90+
mock_result.returncode = 0
91+
with patch("subprocess.run", return_value=mock_result) as mock_run:
92+
exit_code = hook.run()
93+
assert exit_code == 0
94+
mock_run.assert_called_once()
95+
# Verify it ran mypy on current directory
96+
call_args = mock_run.call_args
97+
assert call_args[0][0] == ["poetry", "run", "mypy", "."]
98+
99+
def test_stop_hook_type_errors(self, tmp_path: Path) -> None:
100+
"""Test Stop hook with type errors (exit 1) transforms to exit code 2."""
101+
hook_input = HookInput(
102+
session_id="abc123",
103+
tool_input={},
104+
raw={
105+
"session_id": "abc123",
106+
"hook_event_name": "Stop",
107+
"stop_hook_active": True,
108+
},
109+
)
110+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
111+
hook = MypyHook(hook_input)
112+
mock_result = MagicMock()
113+
mock_result.returncode = 1
114+
with patch("subprocess.run", return_value=mock_result):
115+
exit_code = hook.run()
116+
assert exit_code == 2
117+
118+
def test_empty_file_path_runs_full_check(self, tmp_path: Path) -> None:
119+
"""Test empty string file_path is treated as Stop hook."""
120+
hook_input = HookInput(
121+
session_id=None,
122+
tool_input={"file_path": ""},
123+
raw={},
124+
)
125+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
126+
hook = MypyHook(hook_input)
127+
mock_result = MagicMock()
128+
mock_result.returncode = 0
129+
with patch("subprocess.run", return_value=mock_result) as mock_run:
130+
exit_code = hook.run()
131+
assert exit_code == 0
132+
# Verify it ran mypy on current directory
133+
call_args = mock_run.call_args
134+
assert call_args[0][0] == ["poetry", "run", "mypy", "."]

0 commit comments

Comments
 (0)