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

1"""Production-ready parser for crackerjack hook output. 

2 

3This module provides robust parsing of hook output lines in the format: 

4 hook_name + padding_dots + single_space + status_marker 

5 

6The parser uses reverse string parsing to reliably extract hook names 

7regardless of their content (including dots, dashes, underscores, etc.). 

8""" 

9 

10from __future__ import annotations 

11 

12from typing import NamedTuple 

13 

14 

15class HookResult(NamedTuple): 

16 """Parsed hook result containing name and status. 

17 

18 Attributes: 

19 hook_name: The name of the hook (may contain dots, dashes, etc.) 

20 passed: True if hook passed, False if failed 

21 

22 """ 

23 

24 hook_name: str 

25 passed: bool 

26 

27 

28class ParseError(ValueError): 

29 """Raised when a line cannot be parsed as valid hook output.""" 

30 

31 

32# Status marker definitions 

33_PASS_MARKERS = frozenset(["✅", "Passed"]) 

34_FAIL_MARKERS = frozenset(["❌", "Failed"]) 

35_ALL_MARKERS = _PASS_MARKERS | _FAIL_MARKERS 

36 

37 

38def _validate_line(stripped: str, original_line: str) -> None: 

39 """Validate basic line requirements. 

40 

41 Args: 

42 stripped: The stripped line to validate 

43 original_line: The original line for error messages 

44 

45 Raises: 

46 ParseError: If validation fails 

47 

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) 

52 

53 

54def _extract_parts(stripped: str, original_line: str) -> tuple[str, str]: 

55 """Extract left part and status marker from line. 

56 

57 Args: 

58 stripped: The stripped line to parse 

59 original_line: The original line for error messages 

60 

61 Returns: 

62 Tuple of (left_part, status_marker) 

63 

64 Raises: 

65 ParseError: If parts cannot be extracted 

66 

67 """ 

68 parts = stripped.rsplit(maxsplit=1) 

69 

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) 

76 

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) 

80 

81 return parts[0], parts[1] 

82 

83 

84def _validate_status_marker(status_marker: str) -> bool: 

85 """Validate status marker and return pass status. 

86 

87 Args: 

88 status_marker: The status marker to validate 

89 

90 Returns: 

91 True if passed, False if failed 

92 

93 Raises: 

94 ParseError: If status marker is invalid 

95 

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 

101 

102 

103def _extract_hook_name(left_part: str) -> str: 

104 """Extract hook name from left part by stripping padding dots. 

105 

106 Args: 

107 left_part: The left part of the parsed line 

108 

109 Returns: 

110 The extracted hook name 

111 

112 Raises: 

113 ParseError: If hook name cannot be extracted 

114 

115 """ 

116 if not left_part: 

117 msg = "No hook name found before status marker" 

118 raise ParseError(msg) 

119 

120 hook_name = left_part.rstrip(".") 

121 

122 if not hook_name: 

123 msg = "Hook name consists entirely of dots" 

124 raise ParseError(msg) 

125 

126 return hook_name 

127 

128 

129def parse_hook_line(line: str) -> HookResult: 

130 """Parse a crackerjack hook output line into name and status. 

131 

132 The format is: hook_name + padding_dots + single_space + status_marker 

133 

134 Examples: 

135 >>> parse_hook_line( 

136 ... "refurb................................................................ ❌" 

137 ... ) 

138 HookResult(hook_name='refurb', passed=False) 

139 

140 >>> parse_hook_line( 

141 ... "my...custom...hook.................................................... ✅" 

142 ... ) 

143 HookResult(hook_name='my...custom...hook', passed=True) 

144 

145 >>> parse_hook_line( 

146 ... "test.integration.api.................................................. Passed" 

147 ... ) 

148 HookResult(hook_name='test.integration.api', passed=True) 

149 

150 Args: 

151 line: A single line of hook output to parse 

152 

153 Returns: 

154 HookResult with extracted hook_name and passed status 

155 

156 Raises: 

157 ParseError: If line is empty, has no valid status marker, or is malformed 

158 

159 """ 

160 stripped = line.strip() 

161 _validate_line(stripped, line) 

162 

163 left_part, status_marker = _extract_parts(stripped, line) 

164 passed = _validate_status_marker(status_marker) 

165 hook_name = _extract_hook_name(left_part) 

166 

167 return HookResult(hook_name=hook_name, passed=passed) 

168 

169 

170def parse_hook_output(output: str) -> list[HookResult]: 

171 """Parse multiple lines of crackerjack hook output. 

172 

173 Skips empty lines and returns parsed results for valid lines. 

174 Invalid lines raise ParseError with context. 

175 

176 Args: 

177 output: Multi-line string of hook output 

178 

179 Returns: 

180 List of HookResult objects in order 

181 

182 Raises: 

183 ParseError: If any non-empty line cannot be parsed 

184 

185 """ 

186 results: list[HookResult] = [] 

187 

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 

192 

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 

200 

201 return results 

202 

203 

204# Convenience function for common use case 

205def extract_failed_hooks(output: str) -> list[str]: 

206 """Extract names of all failed hooks from output. 

207 

208 Args: 

209 output: Multi-line string of hook output 

210 

211 Returns: 

212 List of hook names that failed 

213 

214 """ 

215 results = parse_hook_output(output) 

216 return [result.hook_name for result in results if not result.passed]