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

2

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

4 hook_name + padding_dots + single_space + status_marker

5

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

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

8"""

9

10 from __future__ import annotations

11

12 from typing import NamedTuple

13

14

15 class 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

28 class 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

38 def _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 ↛ 50 line 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

54 def _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 ↛ 72 line 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 ↛ 78 line 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

84 def _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 ↛ 100 line 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

103 def _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

129 def 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

170 def 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 ↛ 191 line 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

205 def 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]