Skip to content

Commit 22d333b

Browse files
committed
PR feedback
1 parent 71d66e3 commit 22d333b

8 files changed

Lines changed: 678 additions & 22 deletions

File tree

.github/workflows/ci-docs.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,8 @@ jobs:
174174
with:
175175
python-version: '3.12'
176176

177-
- name: Install pytest
178-
run: pip install pytest
177+
- name: Install test dependencies
178+
run: pip install pytest pyyaml
179179

180180
- name: Run script tests
181181
run: python -m pytest scripts/tests/ -v

.llm/skills/scripting.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,24 @@ Alternative: `errors="replace"` for best-effort reading (grep-like hooks).
5757

5858
#### Read Error Propagation
5959

60-
Hooks that cannot read a file must **fail**, not silently pass. Return the error
61-
in the issues list so `main()` sees it:
60+
Hooks that cannot read a file must **fail**, not silently pass.
61+
62+
For **list-returning** hooks (`check_file() -> list[str]`), return the error
63+
in the issues list so `main()` sees it. Do **not** also print -- `main()`
64+
prints returned issues, so printing here causes duplicate output:
6265

6366
```python
6467
except (OSError, UnicodeDecodeError) as exc:
65-
msg = f"{path}:0: cannot read file: {exc}"
66-
print(msg, file=sys.stderr)
67-
return [msg] # NOT return [] -- that silently passes
68+
return [f"{path}:0: cannot read file: {exc}"] # NOT return [] -- that silently passes
69+
```
70+
71+
For **fixer hooks** (`fix_file() -> bool | None`), print to stderr and return
72+
`None` to signal an error (distinct from `False` = no change needed):
73+
74+
```python
75+
except (OSError, UnicodeDecodeError) as exc:
76+
print(f"{path}:0: cannot read file: {exc}", file=sys.stderr)
77+
return None # NOT return False -- that silently passes
6878
```
6979

7080
#### Parse Error Line Numbers
@@ -79,6 +89,34 @@ print(f"{path}:{line}: TOML error: {e}", file=sys.stderr)
7989
Line number attributes: `tomllib.TOMLDecodeError.lineno`,
8090
`json.JSONDecodeError.lineno`, `yaml.YAMLError.problem_mark.line` (0-based).
8191

92+
#### Fixer Hook Pattern
93+
94+
Fixer hooks modify files in-place. They must use `bool | None` return type
95+
so `main()` can distinguish "unchanged" from "error":
96+
97+
```python
98+
def fix_file(filepath: str) -> bool | None:
99+
"""Fix something. Returns True if modified, False if unchanged, None on error."""
100+
try:
101+
content = Path(filepath).read_bytes()
102+
# ... fix logic ...
103+
return True # or False
104+
except OSError as exc: # Use (OSError, UnicodeDecodeError) for read_text()
105+
print(f"{filepath}:0: cannot read file: {exc}", file=sys.stderr)
106+
return None # NOT False -- False means "no change", None means "error"
107+
108+
def main() -> int:
109+
had_error = False
110+
modified = False
111+
for filepath in sys.argv[1:]:
112+
result = fix_file(filepath)
113+
if result is True:
114+
modified = True
115+
elif result is None:
116+
had_error = True
117+
return 1 if modified or had_error else 0
118+
```
119+
82120
#### General Pattern
83121

84122
```python

scripts/hooks/end-of-file-fixer.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
from pathlib import Path
66

77

8-
def fix_file(filepath: str) -> bool:
9-
"""Ensure file ends with exactly one newline. Returns True if modified."""
8+
def fix_file(filepath: str) -> bool | None:
9+
"""Ensure file ends with exactly one newline.
10+
11+
Returns True if modified, False if unchanged, None on error.
12+
"""
1013
path = Path(filepath)
1114
try:
1215
content = path.read_bytes()
@@ -25,19 +28,23 @@ def fix_file(filepath: str) -> bool:
2528
return False
2629
except OSError as exc:
2730
print(f"{filepath}:0: cannot read file: {exc}", file=sys.stderr)
28-
return False
31+
return None
2932

3033

3134
def main() -> int:
3235
if len(sys.argv) < 2:
3336
return 0
3437

38+
had_error = False
3539
modified = False
3640
for filepath in sys.argv[1:]:
37-
if fix_file(filepath):
41+
result = fix_file(filepath)
42+
if result is True:
3843
modified = True
44+
elif result is None:
45+
had_error = True
3946

40-
return 1 if modified else 0
47+
return 1 if modified or had_error else 0
4148

4249

4350
if __name__ == "__main__":

scripts/hooks/mixed-line-ending.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
from pathlib import Path
66

77

8-
def fix_file(filepath: str) -> bool:
9-
"""Convert all line endings to LF. Returns True if modified."""
8+
def fix_file(filepath: str) -> bool | None:
9+
"""Convert all line endings to LF.
10+
11+
Returns True if modified, False if unchanged, None on error.
12+
"""
1013
path = Path(filepath)
1114
try:
1215
content = path.read_bytes()
@@ -22,19 +25,23 @@ def fix_file(filepath: str) -> bool:
2225
return False
2326
except OSError as exc:
2427
print(f"{filepath}:0: cannot read file: {exc}", file=sys.stderr)
25-
return False
28+
return None
2629

2730

2831
def main() -> int:
2932
if len(sys.argv) < 2:
3033
return 0
3134

35+
had_error = False
3236
modified = False
3337
for filepath in sys.argv[1:]:
34-
if fix_file(filepath):
38+
result = fix_file(filepath)
39+
if result is True:
3540
modified = True
41+
elif result is None:
42+
had_error = True
3643

37-
return 1 if modified else 0
44+
return 1 if modified or had_error else 0
3845

3946

4047
if __name__ == "__main__":

scripts/hooks/trailing-whitespace.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
from pathlib import Path
66

77

8-
def fix_file(filepath: str) -> bool:
9-
"""Remove trailing whitespace from a file. Returns True if modified."""
8+
def fix_file(filepath: str) -> bool | None:
9+
"""Remove trailing whitespace from a file.
10+
11+
Returns True if modified, False if unchanged, None on error.
12+
"""
1013
path = Path(filepath)
1114
try:
1215
content = path.read_text(encoding="utf-8")
@@ -39,19 +42,23 @@ def fix_file(filepath: str) -> bool:
3942
return modified
4043
except (OSError, UnicodeDecodeError) as exc:
4144
print(f"{filepath}:0: cannot read file: {exc}", file=sys.stderr)
42-
return False
45+
return None
4346

4447

4548
def main() -> int:
4649
if len(sys.argv) < 2:
4750
return 0
4851

52+
had_error = False
4953
modified = False
5054
for filepath in sys.argv[1:]:
51-
if fix_file(filepath):
55+
result = fix_file(filepath)
56+
if result is True:
5257
modified = True
58+
elif result is None:
59+
had_error = True
5360

54-
return 1 if modified else 0
61+
return 1 if modified or had_error else 0
5562

5663

5764
if __name__ == "__main__":
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Unit tests for end-of-file-fixer.py hook.
4+
5+
Verifies that the end-of-file fixer correctly ensures files end with a single
6+
newline, handles errors, and that output follows conventions.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import importlib.util
12+
import sys
13+
from pathlib import Path
14+
15+
import pytest
16+
17+
# Import the hook module (hyphenated filename requires importlib)
18+
scripts_dir = Path(__file__).parent.parent
19+
spec = importlib.util.spec_from_file_location(
20+
"end_of_file_fixer", scripts_dir / "hooks" / "end-of-file-fixer.py"
21+
)
22+
end_of_file_fixer = importlib.util.module_from_spec(spec)
23+
spec.loader.exec_module(end_of_file_fixer)
24+
25+
fix_file = end_of_file_fixer.fix_file
26+
main = end_of_file_fixer.main
27+
28+
29+
def _write_bytes(directory: Path, name: str, content: bytes) -> Path:
30+
"""Helper to create a file with given byte content."""
31+
filepath = directory / name
32+
filepath.parent.mkdir(parents=True, exist_ok=True)
33+
filepath.write_bytes(content)
34+
return filepath
35+
36+
37+
class TestFixFile:
38+
"""Tests for fix_file() functionality."""
39+
40+
def test_correct_ending_unchanged(self, tmp_path: Path) -> None:
41+
"""A file ending with exactly one newline is not modified."""
42+
f = _write_bytes(tmp_path, "good.txt", b"hello\nworld\n")
43+
assert fix_file(str(f)) is False
44+
assert f.read_bytes() == b"hello\nworld\n"
45+
46+
def test_missing_newline_added(self, tmp_path: Path) -> None:
47+
"""A file missing a trailing newline gets one added."""
48+
f = _write_bytes(tmp_path, "no_nl.txt", b"hello\nworld")
49+
assert fix_file(str(f)) is True
50+
assert f.read_bytes() == b"hello\nworld\n"
51+
52+
def test_extra_newlines_removed(self, tmp_path: Path) -> None:
53+
"""Extra trailing newlines are reduced to one."""
54+
f = _write_bytes(tmp_path, "extra.txt", b"hello\nworld\n\n\n")
55+
assert fix_file(str(f)) is True
56+
assert f.read_bytes() == b"hello\nworld\n"
57+
58+
def test_trailing_spaces_removed(self, tmp_path: Path) -> None:
59+
"""Trailing whitespace (spaces/tabs) at end of file is removed."""
60+
f = _write_bytes(tmp_path, "ws.txt", b"hello\nworld\n \t\n")
61+
assert fix_file(str(f)) is True
62+
assert f.read_bytes() == b"hello\nworld\n"
63+
64+
def test_crlf_trailing_removed(self, tmp_path: Path) -> None:
65+
"""Trailing CRLF endings are normalized."""
66+
f = _write_bytes(tmp_path, "crlf.txt", b"hello\r\n\r\n")
67+
assert fix_file(str(f)) is True
68+
assert f.read_bytes() == b"hello\n"
69+
70+
def test_empty_file_unchanged(self, tmp_path: Path) -> None:
71+
"""An empty file is not modified."""
72+
f = _write_bytes(tmp_path, "empty.txt", b"")
73+
assert fix_file(str(f)) is False
74+
75+
def test_prints_fixed_message_on_modification(
76+
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
77+
) -> None:
78+
"""Prints 'Fixed: <path>' to stdout when file is modified."""
79+
f = _write_bytes(tmp_path, "no_nl.txt", b"hello")
80+
fix_file(str(f))
81+
captured = capsys.readouterr()
82+
assert f"Fixed: {f}" in captured.out
83+
84+
def test_no_output_when_unchanged(
85+
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
86+
) -> None:
87+
"""No output when file is unchanged."""
88+
f = _write_bytes(tmp_path, "good.txt", b"hello\n")
89+
fix_file(str(f))
90+
captured = capsys.readouterr()
91+
assert captured.out == ""
92+
assert captured.err == ""
93+
94+
95+
class TestErrorHandling:
96+
"""Tests that read errors cause non-zero exit (fail-closed)."""
97+
98+
def test_nonexistent_file_returns_none(self, tmp_path: Path) -> None:
99+
"""Nonexistent file returns None (error), not False."""
100+
result = fix_file(str(tmp_path / "nonexistent.txt"))
101+
assert result is None
102+
103+
def test_nonexistent_file_prints_to_stderr(
104+
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
105+
) -> None:
106+
"""Nonexistent file prints error to stderr with :0: format."""
107+
fix_file(str(tmp_path / "nonexistent.txt"))
108+
captured = capsys.readouterr()
109+
assert ":0:" in captured.err
110+
assert "cannot read file" in captured.err
111+
112+
def test_unreadable_file_returns_none(self, tmp_path: Path) -> None:
113+
"""Unreadable file returns None (error), not False."""
114+
f = _write_bytes(tmp_path, "noperm.txt", b"hello")
115+
f.chmod(0o000)
116+
try:
117+
result = fix_file(str(f))
118+
assert result is None
119+
finally:
120+
f.chmod(0o644)
121+
122+
def test_main_nonexistent_file_returns_one(
123+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
124+
) -> None:
125+
"""main() returns 1 when a file cannot be read (fail-closed)."""
126+
nonexistent = tmp_path / "missing.txt"
127+
monkeypatch.setattr(
128+
sys, "argv", ["end-of-file-fixer.py", str(nonexistent)]
129+
)
130+
assert main() == 1
131+
132+
def test_main_error_and_clean_file_returns_one(
133+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
134+
) -> None:
135+
"""main() returns 1 when any file has an error, even if others are clean."""
136+
clean = _write_bytes(tmp_path, "clean.txt", b"hello\n")
137+
missing = tmp_path / "missing.txt"
138+
monkeypatch.setattr(
139+
sys, "argv", ["end-of-file-fixer.py", str(clean), str(missing)]
140+
)
141+
assert main() == 1
142+
143+
def test_main_error_and_modified_file_returns_one(
144+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
145+
) -> None:
146+
"""main() returns 1 when one file errors and another is modified."""
147+
dirty = _write_bytes(tmp_path, "dirty.txt", b"hello")
148+
missing = tmp_path / "missing.txt"
149+
monkeypatch.setattr(
150+
sys, "argv", ["end-of-file-fixer.py", str(dirty), str(missing)]
151+
)
152+
assert main() == 1
153+
# The dirty file should still have been fixed
154+
assert dirty.read_bytes() == b"hello\n"
155+
156+
157+
class TestMain:
158+
"""Tests for the main() entry point."""
159+
160+
def test_main_no_args_returns_zero(
161+
self, monkeypatch: pytest.MonkeyPatch
162+
) -> None:
163+
monkeypatch.setattr(sys, "argv", ["end-of-file-fixer.py"])
164+
assert main() == 0
165+
166+
def test_main_clean_file_returns_zero(
167+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
168+
) -> None:
169+
f = _write_bytes(tmp_path, "good.txt", b"hello\n")
170+
monkeypatch.setattr(
171+
sys, "argv", ["end-of-file-fixer.py", str(f)]
172+
)
173+
assert main() == 0
174+
175+
def test_main_modified_file_returns_one(
176+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
177+
) -> None:
178+
f = _write_bytes(tmp_path, "no_nl.txt", b"hello")
179+
monkeypatch.setattr(
180+
sys, "argv", ["end-of-file-fixer.py", str(f)]
181+
)
182+
assert main() == 1
183+
184+
def test_main_multiple_files(
185+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
186+
) -> None:
187+
"""Returns 1 if any file is modified."""
188+
clean = _write_bytes(tmp_path, "good.txt", b"hello\n")
189+
dirty = _write_bytes(tmp_path, "no_nl.txt", b"hello")
190+
monkeypatch.setattr(
191+
sys, "argv", ["end-of-file-fixer.py", str(clean), str(dirty)]
192+
)
193+
assert main() == 1
194+
195+
196+
if __name__ == "__main__":
197+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)