Coverage for session_buddy / tools / hook_parser.py: 62.67%
57 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
1"""Production-ready parser for crackerjack hook output.
3This module provides robust parsing of hook output lines in the format:
4 hook_name + padding_dots + single_space + status_marker
6The parser uses reverse string parsing to reliably extract hook names
7regardless of their content (including dots, dashes, underscores, etc.).
8"""
10from __future__ import annotations
12from typing import NamedTuple
15class HookResult(NamedTuple):
16 """Parsed hook result containing name and status.
18 Attributes:
19 hook_name: The name of the hook (may contain dots, dashes, etc.)
20 passed: True if hook passed, False if failed
22 """
24 hook_name: str
25 passed: bool
28class ParseError(ValueError):
29 """Raised when a line cannot be parsed as valid hook output."""
32# Status marker definitions
33_PASS_MARKERS = frozenset(["✅", "Passed"])
34_FAIL_MARKERS = frozenset(["❌", "Failed"])
35_ALL_MARKERS = _PASS_MARKERS | _FAIL_MARKERS
38def _validate_line(stripped: str, original_line: str) -> None:
39 """Validate basic line requirements.
41 Args:
42 stripped: The stripped line to validate
43 original_line: The original line for error messages
45 Raises:
46 ParseError: If validation fails
48 """
49 if not stripped: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 msg = "Cannot parse empty line"
51 raise ParseError(msg)
54def _extract_parts(stripped: str, original_line: str) -> tuple[str, str]:
55 """Extract left part and status marker from line.
57 Args:
58 stripped: The stripped line to parse
59 original_line: The original line for error messages
61 Returns:
62 Tuple of (left_part, status_marker)
64 Raises:
65 ParseError: If parts cannot be extracted
67 """
68 parts = stripped.rsplit(maxsplit=1)
70 if len(parts) == 1:
71 if parts[0] in _ALL_MARKERS: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 msg = "No hook name found before status marker"
73 raise ParseError(msg)
74 msg = f"Line has no space-separated status marker: {original_line!r}"
75 raise ParseError(msg)
77 if len(parts) != 2: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 msg = f"Line has no space-separated status marker: {original_line!r}"
79 raise ParseError(msg)
81 return parts[0], parts[1]
84def _validate_status_marker(status_marker: str) -> bool:
85 """Validate status marker and return pass status.
87 Args:
88 status_marker: The status marker to validate
90 Returns:
91 True if passed, False if failed
93 Raises:
94 ParseError: If status marker is invalid
96 """
97 if status_marker not in _ALL_MARKERS: 97 ↛ 100line 97 didn't jump to line 100 because the condition on line 97 was always true
98 msg = f"Unknown status marker: {status_marker!r}"
99 raise ParseError(msg)
100 return status_marker in _PASS_MARKERS
103def _extract_hook_name(left_part: str) -> str:
104 """Extract hook name from left part by stripping padding dots.
106 Args:
107 left_part: The left part of the parsed line
109 Returns:
110 The extracted hook name
112 Raises:
113 ParseError: If hook name cannot be extracted
115 """
116 if not left_part:
117 msg = "No hook name found before status marker"
118 raise ParseError(msg)
120 hook_name = left_part.rstrip(".")
122 if not hook_name:
123 msg = "Hook name consists entirely of dots"
124 raise ParseError(msg)
126 return hook_name
129def parse_hook_line(line: str) -> HookResult:
130 """Parse a crackerjack hook output line into name and status.
132 The format is: hook_name + padding_dots + single_space + status_marker
134 Examples:
135 >>> parse_hook_line(
136 ... "refurb................................................................ ❌"
137 ... )
138 HookResult(hook_name='refurb', passed=False)
140 >>> parse_hook_line(
141 ... "my...custom...hook.................................................... ✅"
142 ... )
143 HookResult(hook_name='my...custom...hook', passed=True)
145 >>> parse_hook_line(
146 ... "test.integration.api.................................................. Passed"
147 ... )
148 HookResult(hook_name='test.integration.api', passed=True)
150 Args:
151 line: A single line of hook output to parse
153 Returns:
154 HookResult with extracted hook_name and passed status
156 Raises:
157 ParseError: If line is empty, has no valid status marker, or is malformed
159 """
160 stripped = line.strip()
161 _validate_line(stripped, line)
163 left_part, status_marker = _extract_parts(stripped, line)
164 passed = _validate_status_marker(status_marker)
165 hook_name = _extract_hook_name(left_part)
167 return HookResult(hook_name=hook_name, passed=passed)
170def parse_hook_output(output: str) -> list[HookResult]:
171 """Parse multiple lines of crackerjack hook output.
173 Skips empty lines and returns parsed results for valid lines.
174 Invalid lines raise ParseError with context.
176 Args:
177 output: Multi-line string of hook output
179 Returns:
180 List of HookResult objects in order
182 Raises:
183 ParseError: If any non-empty line cannot be parsed
185 """
186 results: list[HookResult] = []
188 for line_num, line in enumerate(output.splitlines(), start=1):
189 # Skip empty lines
190 if not line.strip(): 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 continue
193 try:
194 result = parse_hook_line(line)
195 results.append(result)
196 except ParseError as e:
197 # Add line number context for debugging
198 msg = f"Line {line_num}: {e}"
199 raise ParseError(msg) from e
201 return results
204# Convenience function for common use case
205def extract_failed_hooks(output: str) -> list[str]:
206 """Extract names of all failed hooks from output.
208 Args:
209 output: Multi-line string of hook output
211 Returns:
212 List of hook names that failed
214 """
215 results = parse_hook_output(output)
216 return [result.hook_name for result in results if not result.passed]