claude-dev-env 8.35.2 → 8.35.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -13,7 +13,6 @@ Project root is discovered via CLAUDE_PROJECT_ROOT env var or git rev-parse.
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
import hashlib
|
|
16
|
-
import importlib
|
|
17
16
|
import json
|
|
18
17
|
import os
|
|
19
18
|
import platform
|
|
@@ -21,13 +20,9 @@ import stat
|
|
|
21
20
|
import subprocess
|
|
22
21
|
import sys
|
|
23
22
|
from pathlib import Path
|
|
24
|
-
from types import ModuleType
|
|
25
23
|
|
|
26
24
|
_hooks_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
27
25
|
|
|
28
|
-
_notification_utils_directory = os.path.join(_hooks_directory, "notification")
|
|
29
|
-
sys.path.insert(0, _notification_utils_directory)
|
|
30
|
-
|
|
31
26
|
_validators_directory = os.path.join(_hooks_directory, "validators")
|
|
32
27
|
if _validators_directory not in sys.path:
|
|
33
28
|
sys.path.insert(0, _validators_directory)
|
|
@@ -59,14 +54,6 @@ from json_file_reader import read_json_object # noqa: E402
|
|
|
59
54
|
|
|
60
55
|
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
61
56
|
|
|
62
|
-
|
|
63
|
-
def load_notification_utils() -> ModuleType | None:
|
|
64
|
-
try:
|
|
65
|
-
return importlib.import_module("notification_utils")
|
|
66
|
-
except ImportError:
|
|
67
|
-
return None
|
|
68
|
-
|
|
69
|
-
|
|
70
57
|
IS_WINDOWS = platform.system() == "Windows"
|
|
71
58
|
|
|
72
59
|
GIT_COMMAND_TIMEOUT_SECONDS = 5
|
|
@@ -530,25 +517,6 @@ def format_error_summary(all_error_lines: list[str]) -> str:
|
|
|
530
517
|
return error_summary
|
|
531
518
|
|
|
532
519
|
|
|
533
|
-
def send_block_notification(error_summary: str) -> None:
|
|
534
|
-
notification_module = load_notification_utils()
|
|
535
|
-
if notification_module is None:
|
|
536
|
-
return
|
|
537
|
-
|
|
538
|
-
notification_title = "Mypy Type Errors"
|
|
539
|
-
notification_body = f"Write blocked: {error_summary[:200]}"
|
|
540
|
-
|
|
541
|
-
try:
|
|
542
|
-
if notification_module.is_wsl():
|
|
543
|
-
notification_module.notify_wsl(notification_title, notification_body)
|
|
544
|
-
elif platform.system() == "Linux":
|
|
545
|
-
notification_module.notify_linux()
|
|
546
|
-
elif platform.system() == "Windows":
|
|
547
|
-
notification_module.notify_windows(notification_title, notification_body)
|
|
548
|
-
except (AttributeError, OSError):
|
|
549
|
-
pass
|
|
550
|
-
|
|
551
|
-
|
|
552
520
|
def build_block_response(error_summary: str) -> dict[str, str | dict[str, str]]:
|
|
553
521
|
return {
|
|
554
522
|
"decision": "block",
|
|
@@ -615,7 +583,6 @@ def main() -> None:
|
|
|
615
583
|
sys.exit(0)
|
|
616
584
|
|
|
617
585
|
error_summary = format_error_summary(all_error_lines)
|
|
618
|
-
send_block_notification(error_summary)
|
|
619
586
|
block_response = build_block_response(error_summary)
|
|
620
587
|
log_hook_block(
|
|
621
588
|
calling_hook_name="mypy_validator.py",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The mypy_validator hook reports a block through its reply and nothing else.
|
|
2
|
+
|
|
3
|
+
An install from before the package dropped ``hooks/notification`` keeps
|
|
4
|
+
``notification_utils.py`` on disk. This test puts a recording stand-in for
|
|
5
|
+
that module on the import path and checks a blocked write never reads it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
|
|
18
|
+
TYPE_ERROR_MODULE_SOURCE = 'value: int = "not an integer"\n'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_validator() -> ModuleType:
|
|
22
|
+
spec = importlib.util.spec_from_file_location("mypy_validator_toast_under_test", HOOK_PATH)
|
|
23
|
+
assert spec is not None and spec.loader is not None
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
spec.loader.exec_module(module)
|
|
26
|
+
return module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_recording_notification_module(all_touched_names: list[str]) -> ModuleType:
|
|
30
|
+
class RecordingNotificationModule(ModuleType):
|
|
31
|
+
def __getattr__(self, attribute_name: str) -> object:
|
|
32
|
+
if not attribute_name.startswith("__"):
|
|
33
|
+
all_touched_names.append(attribute_name)
|
|
34
|
+
return lambda *_arguments, **_options: False
|
|
35
|
+
|
|
36
|
+
return RecordingNotificationModule("notification_utils")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_type_error_block_opens_no_desktop_toast(
|
|
40
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
41
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
42
|
+
capsys: pytest.CaptureFixture[str],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""A blocked write reports through the hook reply and leaves the desktop alone.
|
|
45
|
+
|
|
46
|
+
::
|
|
47
|
+
|
|
48
|
+
mypy typed_module.py -> error: Incompatible types
|
|
49
|
+
ok: {"decision": "block"} on stdout, notification_utils untouched
|
|
50
|
+
flag: notify_windows() opens a toast window at the bottom of the screen
|
|
51
|
+
"""
|
|
52
|
+
validator = _load_validator()
|
|
53
|
+
project_root = tmp_path_factory.mktemp("blocked_project")
|
|
54
|
+
target_file = project_root / "typed_module.py"
|
|
55
|
+
target_file.write_text(TYPE_ERROR_MODULE_SOURCE, encoding="utf-8")
|
|
56
|
+
all_touched_names: list[str] = []
|
|
57
|
+
monkeypatch.setitem(
|
|
58
|
+
sys.modules,
|
|
59
|
+
"notification_utils",
|
|
60
|
+
_build_recording_notification_module(all_touched_names),
|
|
61
|
+
)
|
|
62
|
+
monkeypatch.setattr(validator, "discover_project_root", lambda _target: project_root)
|
|
63
|
+
monkeypatch.setattr(
|
|
64
|
+
validator,
|
|
65
|
+
"run_mypy",
|
|
66
|
+
lambda _target, _root: (1, f"{target_file}:1: error: Incompatible types\n"),
|
|
67
|
+
)
|
|
68
|
+
monkeypatch.setattr(validator, "log_hook_block", lambda **_details: None)
|
|
69
|
+
monkeypatch.setattr(
|
|
70
|
+
sys, "stdin", io.StringIO(json.dumps({"tool_input": {"file_path": str(target_file)}}))
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
with pytest.raises(SystemExit):
|
|
74
|
+
validator.main()
|
|
75
|
+
|
|
76
|
+
assert json.loads(capsys.readouterr().out)["decision"] == "block"
|
|
77
|
+
assert all_touched_names == []
|
|
@@ -4,15 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
import importlib
|
|
8
7
|
import json
|
|
9
8
|
import os
|
|
10
|
-
import platform
|
|
11
9
|
import subprocess
|
|
12
10
|
import sys
|
|
13
11
|
from collections.abc import Mapping
|
|
14
12
|
from pathlib import Path
|
|
15
|
-
from types import ModuleType
|
|
16
13
|
|
|
17
14
|
_subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[2] / "hooks")
|
|
18
15
|
if _subprocess_window_hooks_directory not in sys.path:
|
|
@@ -20,11 +17,6 @@ if _subprocess_window_hooks_directory not in sys.path:
|
|
|
20
17
|
|
|
21
18
|
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
22
19
|
|
|
23
|
-
NOTIFICATION_UTILS_DIRECTORY = os.path.join(
|
|
24
|
-
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "notification"
|
|
25
|
-
)
|
|
26
|
-
sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
|
|
27
|
-
|
|
28
20
|
PYTHON_EXTENSIONS = frozenset({".py"})
|
|
29
21
|
JS_EXTENSIONS = frozenset({".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"})
|
|
30
22
|
JSON_EXTENSIONS = frozenset({".json"})
|
|
@@ -57,32 +49,6 @@ PRETTIER_CONFIG_NAMES = frozenset(
|
|
|
57
49
|
)
|
|
58
50
|
|
|
59
51
|
|
|
60
|
-
def load_notification_utils() -> ModuleType | None:
|
|
61
|
-
try:
|
|
62
|
-
return importlib.import_module("notification_utils")
|
|
63
|
-
except ImportError:
|
|
64
|
-
return None
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def send_format_notification(file_path: str, formatter_name: str) -> None:
|
|
68
|
-
notification_module = load_notification_utils()
|
|
69
|
-
if notification_module is None:
|
|
70
|
-
return
|
|
71
|
-
|
|
72
|
-
notification_title = "Auto-Formatter"
|
|
73
|
-
notification_body = f"{formatter_name} formatted: {Path(file_path).name}"
|
|
74
|
-
|
|
75
|
-
try:
|
|
76
|
-
if notification_module.is_wsl():
|
|
77
|
-
notification_module.notify_wsl(notification_title, notification_body)
|
|
78
|
-
elif platform.system() == "Linux":
|
|
79
|
-
notification_module.notify_linux()
|
|
80
|
-
elif platform.system() == "Windows":
|
|
81
|
-
notification_module.notify_windows(notification_title, notification_body)
|
|
82
|
-
except (AttributeError, OSError):
|
|
83
|
-
pass
|
|
84
|
-
|
|
85
|
-
|
|
86
52
|
def has_prettier_config(file_path: str) -> bool:
|
|
87
53
|
each_ancestor = Path(file_path).resolve().parent
|
|
88
54
|
while True:
|
|
@@ -249,16 +215,9 @@ def _run_python_format_commands(file_path: str) -> None:
|
|
|
249
215
|
if did_timeout:
|
|
250
216
|
return
|
|
251
217
|
if completed_process is not None and completed_process.returncode == 0:
|
|
252
|
-
send_format_notification(file_path, formatter_name_for_command(each_command))
|
|
253
218
|
return
|
|
254
219
|
|
|
255
220
|
|
|
256
|
-
def formatter_name_for_command(command: list[str]) -> str:
|
|
257
|
-
if command[0] == sys.executable:
|
|
258
|
-
return command[2]
|
|
259
|
-
return command[0]
|
|
260
|
-
|
|
261
|
-
|
|
262
221
|
def _run_prettier(file_path: str) -> None:
|
|
263
222
|
prettier_command = [
|
|
264
223
|
NPX_EXECUTABLE,
|
|
@@ -267,11 +226,7 @@ def _run_prettier(file_path: str) -> None:
|
|
|
267
226
|
"--write",
|
|
268
227
|
os.path.realpath(file_path),
|
|
269
228
|
]
|
|
270
|
-
|
|
271
|
-
prettier_command, file_path, JS_FORMAT_TIMEOUT_SECONDS
|
|
272
|
-
)
|
|
273
|
-
if completed_process is not None and completed_process.returncode == 0:
|
|
274
|
-
send_format_notification(file_path, PRETTIER_FORMATTER_NAME)
|
|
229
|
+
_run_command(prettier_command, file_path, JS_FORMAT_TIMEOUT_SECONDS)
|
|
275
230
|
|
|
276
231
|
|
|
277
232
|
def run_eligible_formatter(file_path: str) -> None:
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The auto_formatter hook leaves the desktop alone after a format.
|
|
2
|
+
|
|
3
|
+
An install from before the package dropped ``hooks/notification`` keeps
|
|
4
|
+
``notification_utils.py`` on disk. These tests put a recording stand-in for
|
|
5
|
+
that module on the import path and check the hook never reads it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
HOOK_SCRIPT_PATH = Path(__file__).resolve().parent / "auto_formatter.py"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_auto_formatter_module() -> ModuleType:
|
|
20
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
21
|
+
"auto_formatter_toast_under_test", HOOK_SCRIPT_PATH
|
|
22
|
+
)
|
|
23
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
24
|
+
auto_formatter_module = importlib.util.module_from_spec(module_spec)
|
|
25
|
+
module_spec.loader.exec_module(auto_formatter_module)
|
|
26
|
+
return auto_formatter_module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_recording_notification_module(all_touched_names: list[str]) -> ModuleType:
|
|
30
|
+
class RecordingNotificationModule(ModuleType):
|
|
31
|
+
def __getattr__(self, attribute_name: str) -> object:
|
|
32
|
+
if not attribute_name.startswith("__"):
|
|
33
|
+
all_touched_names.append(attribute_name)
|
|
34
|
+
return lambda *_arguments, **_options: False
|
|
35
|
+
|
|
36
|
+
return RecordingNotificationModule("notification_utils")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_successful_format_opens_no_desktop_toast(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
40
|
+
"""A stale notification_utils on the import path stays untouched after a format.
|
|
41
|
+
|
|
42
|
+
::
|
|
43
|
+
|
|
44
|
+
~/.claude/hooks/notification/notification_utils.py left behind by an old install
|
|
45
|
+
ruff format module.py -> exit 0
|
|
46
|
+
ok: nothing reads notification_utils
|
|
47
|
+
flag: notify_windows() opens a toast window at the bottom of the screen
|
|
48
|
+
"""
|
|
49
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
50
|
+
all_touched_names: list[str] = []
|
|
51
|
+
monkeypatch.setitem(
|
|
52
|
+
sys.modules,
|
|
53
|
+
"notification_utils",
|
|
54
|
+
_build_recording_notification_module(all_touched_names),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def report_formatter_success(
|
|
58
|
+
command: list[str], _file_path: str, _timeout_seconds: int
|
|
59
|
+
) -> tuple[subprocess.CompletedProcess[str], bool]:
|
|
60
|
+
return subprocess.CompletedProcess(command, 0, "", ""), False
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr(auto_formatter_module, "_run_command", report_formatter_success)
|
|
63
|
+
|
|
64
|
+
auto_formatter_module.run_eligible_formatter("formatted_module.py")
|
|
65
|
+
auto_formatter_module._run_prettier("formatted_module.js")
|
|
66
|
+
|
|
67
|
+
assert all_touched_names == []
|