Coverage for lintro / parsers / pytest / format_parsers.py: 86%

172 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Format-specific parsers for pytest output. 

2 

3This module provides functions to parse pytest output in various formats: 

4- JSON output from pytest --json-report 

5- Plain text output from pytest 

6- JUnit XML output from pytest --junitxml 

7""" 

8 

9from __future__ import annotations 

10 

11import json 

12import re 

13 

14from defusedxml import ElementTree 

15 

16from lintro.parsers.base_parser import strip_ansi_codes 

17from lintro.parsers.pytest.pytest_issue import PytestIssue 

18 

19 

20def parse_pytest_json_output(output: str) -> list[PytestIssue]: 

21 """Parse pytest JSON output into PytestIssue objects. 

22 

23 Args: 

24 output: Raw output from pytest with --json-report. 

25 

26 Returns: 

27 list[PytestIssue]: Parsed test failures, errors, and skips. 

28 """ 

29 issues: list[PytestIssue] = [] 

30 

31 if not output or output.strip() in ("{}", "[]"): 

32 return issues 

33 

34 try: 

35 data = json.loads(output) 

36 

37 # Handle different JSON report formats 

38 if "tests" in data: 

39 # pytest-json-report format 

40 for test in data["tests"]: 

41 if test.get("outcome") in ("failed", "error", "skipped"): 

42 issues.append(_parse_json_test_item(test)) 

43 elif isinstance(data, list): 

44 # Alternative JSON format 

45 for item in data: 

46 if isinstance(item, dict) and item.get("outcome") in ( 

47 "failed", 

48 "error", 

49 "skipped", 

50 ): 

51 issues.append(_parse_json_test_item(item)) 

52 

53 except (json.JSONDecodeError, TypeError, KeyError) as e: 

54 from loguru import logger 

55 

56 logger.debug(f"Failed to parse pytest JSON output: {e}") 

57 

58 return issues 

59 

60 

61def _parse_json_test_item(test_item: dict[str, object]) -> PytestIssue: 

62 """Parse a single test item from JSON output. 

63 

64 Args: 

65 test_item: Dictionary containing test information. 

66 

67 Returns: 

68 PytestIssue: Parsed test issue. 

69 """ 

70 file_raw = test_item.get("file") 

71 file_path = file_raw if isinstance(file_raw, str) else "" 

72 

73 line_raw = test_item.get("lineno") 

74 line = int(line_raw) if isinstance(line_raw, int) else 0 

75 

76 name_raw = test_item.get("name") 

77 test_name = name_raw if isinstance(name_raw, str) else "" 

78 

79 call_obj = test_item.get("call") 

80 call_longrepr: str | None = None 

81 if isinstance(call_obj, dict): 

82 call_longrepr_val = call_obj.get("longrepr") 

83 if isinstance(call_longrepr_val, str): 

84 call_longrepr = call_longrepr_val 

85 

86 longrepr_raw = test_item.get("longrepr") 

87 message = call_longrepr or (longrepr_raw if isinstance(longrepr_raw, str) else "") 

88 

89 status_raw = test_item.get("outcome") 

90 status = status_raw if isinstance(status_raw, str) else "UNKNOWN" 

91 

92 duration_raw = test_item.get("duration") 

93 if isinstance(duration_raw, (int, float)): 

94 duration: float | None = float(duration_raw) 

95 else: 

96 duration = 0.0 

97 

98 node_id_raw = test_item.get("nodeid") 

99 node_id: str | None = node_id_raw if isinstance(node_id_raw, str) else "" 

100 

101 return PytestIssue( 

102 file=file_path, 

103 line=line, 

104 test_name=test_name, 

105 message=message, 

106 test_status=status.upper(), 

107 duration=duration, 

108 node_id=node_id, 

109 ) 

110 

111 

112def parse_pytest_text_output(output: str) -> list[PytestIssue]: 

113 """Parse pytest plain text output into PytestIssue objects. 

114 

115 Args: 

116 output: Raw output from pytest. 

117 

118 Returns: 

119 list[PytestIssue]: Parsed test failures, errors, and skips. 

120 """ 

121 issues: list[PytestIssue] = [] 

122 

123 if not output: 

124 return issues 

125 

126 lines = output.splitlines() 

127 current_file = "" 

128 current_line = 0 

129 

130 # Patterns for different pytest output formats 

131 file_pattern = re.compile(r"^(.+\.py)::(.+)$") 

132 failure_pattern = re.compile(r"^FAILED\s+(.+\.py)::(.+)\s+-\s+(.+)$") 

133 error_pattern = re.compile(r"^ERROR\s+(.+\.py)::(.+)\s+-\s+(.+)$") 

134 skipped_pattern = re.compile(r"^(.+\.py)::([^\s]+)\s+SKIPPED\s+\((.+)\)\s+\[") 

135 line_pattern = re.compile(r"^(.+\.py):(\d+):\s+(.+)$") 

136 

137 # Alternative patterns for different pytest output formats 

138 # Use non-greedy matching for test name to stop at first space 

139 failure_pattern_alt = re.compile(r"^FAILED\s+(.+\.py)::([^\s]+)\s+(.+)$") 

140 error_pattern_alt = re.compile(r"^ERROR\s+(.+\.py)::([^\s]+)\s+(.+)$") 

141 # Alternative skipped pattern without trailing bracket (for compact output) 

142 skipped_pattern_alt = re.compile(r"^(.+\.py)::([^\s]+)\s+SKIPPED\s+\((.+)\)$") 

143 

144 for line in lines: 

145 # Strip ANSI color codes for stable parsing 

146 line = strip_ansi_codes(line).strip() 

147 

148 # Match FAILED format 

149 failure_match = failure_pattern.match(line) 

150 if failure_match: 

151 file_path = failure_match.group(1) 

152 test_name = failure_match.group(2) 

153 message = failure_match.group(3) 

154 issues.append( 

155 PytestIssue( 

156 file=file_path, 

157 line=0, 

158 test_name=test_name, 

159 message=message, 

160 test_status="FAILED", 

161 ), 

162 ) 

163 continue 

164 

165 # Match FAILED format (alternative) 

166 failure_match_alt = failure_pattern_alt.match(line) 

167 if failure_match_alt: 

168 file_path = failure_match_alt.group(1) 

169 test_name = failure_match_alt.group(2) 

170 message = failure_match_alt.group(3) 

171 issues.append( 

172 PytestIssue( 

173 file=file_path, 

174 line=0, 

175 test_name=test_name, 

176 message=message, 

177 test_status="FAILED", 

178 ), 

179 ) 

180 continue 

181 

182 # Match ERROR format 

183 error_match = error_pattern.match(line) 

184 if error_match: 

185 file_path = error_match.group(1) 

186 test_name = error_match.group(2) 

187 message = error_match.group(3) 

188 issues.append( 

189 PytestIssue( 

190 file=file_path, 

191 line=0, 

192 test_name=test_name, 

193 message=message, 

194 test_status="ERROR", 

195 ), 

196 ) 

197 continue 

198 

199 # Match ERROR format (alternative) 

200 error_match_alt = error_pattern_alt.match(line) 

201 if error_match_alt: 

202 file_path = error_match_alt.group(1) 

203 test_name = error_match_alt.group(2) 

204 message = error_match_alt.group(3) 

205 issues.append( 

206 PytestIssue( 

207 file=file_path, 

208 line=0, 

209 test_name=test_name, 

210 message=message, 

211 test_status="ERROR", 

212 ), 

213 ) 

214 continue 

215 

216 # Match SKIPPED format 

217 skipped_match = skipped_pattern.match(line) 

218 if skipped_match: 

219 file_path = skipped_match.group(1) 

220 test_name = skipped_match.group(2) 

221 message = skipped_match.group(3) 

222 issues.append( 

223 PytestIssue( 

224 file=file_path, 

225 line=0, 

226 test_name=test_name, 

227 message=message, 

228 test_status="SKIPPED", 

229 ), 

230 ) 

231 continue 

232 

233 # Match SKIPPED format (alternative) 

234 skipped_match_alt = skipped_pattern_alt.match(line) 

235 if skipped_match_alt: 

236 file_path = skipped_match_alt.group(1) 

237 test_name = skipped_match_alt.group(2) 

238 message = skipped_match_alt.group(3) 

239 issues.append( 

240 PytestIssue( 

241 file=file_path, 

242 line=0, 

243 test_name=test_name, 

244 message=message, 

245 test_status="SKIPPED", 

246 ), 

247 ) 

248 continue 

249 

250 # Match file::test format 

251 file_match = file_pattern.match(line) 

252 if file_match: 

253 current_file = file_match.group(1) 

254 continue 

255 

256 # Match line number format 

257 line_match = line_pattern.match(line) 

258 if line_match: 

259 current_file = line_match.group(1) 

260 current_line = int(line_match.group(2)) 

261 message = line_match.group(3) 

262 if "FAILED" in message or "ERROR" in message or "SKIPPED" in message: 

263 # Extract just the error message without the status prefix 

264 if message.startswith("FAILED - "): 

265 message = message[9:] # Remove "FAILED - " 

266 status = "FAILED" 

267 elif message.startswith("ERROR - "): 

268 message = message[8:] # Remove "ERROR - " 

269 status = "ERROR" 

270 elif message.startswith("SKIPPED - "): 

271 message = message[10:] # Remove "SKIPPED - " 

272 status = "SKIPPED" 

273 else: 

274 status = "UNKNOWN" 

275 

276 issues.append( 

277 PytestIssue( 

278 file=current_file, 

279 line=current_line, 

280 test_name="", 

281 message=message, 

282 test_status=status, 

283 ), 

284 ) 

285 

286 return issues 

287 

288 

289def parse_pytest_junit_xml(output: str) -> list[PytestIssue]: 

290 """Parse pytest JUnit XML output into PytestIssue objects. 

291 

292 Args: 

293 output: Raw output from pytest with --junitxml. 

294 

295 Returns: 

296 list[PytestIssue]: Parsed test failures, errors, and skips. 

297 """ 

298 issues: list[PytestIssue] = [] 

299 

300 if not output: 

301 return issues 

302 

303 try: 

304 root = ElementTree.fromstring(output) 

305 

306 # Handle different JUnit XML structures 

307 for testcase in root.findall(".//testcase"): 

308 file_path = testcase.get("file", "") 

309 # Safely parse line number, defaulting to 0 if not numeric 

310 try: 

311 line = int(testcase.get("line", 0)) 

312 except (ValueError, TypeError): 

313 line = 0 

314 test_name = testcase.get("name", "") 

315 # Safely parse duration, defaulting to 0.0 if not numeric 

316 try: 

317 duration = float(testcase.get("time", 0.0)) 

318 except (ValueError, TypeError): 

319 duration = 0.0 

320 class_name = testcase.get("classname", "") 

321 # If file attribute is missing, try to derive it from classname 

322 if not file_path and class_name: 

323 # Convert class name like 

324 # "tests.scripts.test_script_environment.TestEnvironmentHandling" 

325 # to file path like "tests/scripts/test_script_environment.py" 

326 class_parts = class_name.split(".") 

327 if len(class_parts) >= 2 and class_parts[0] == "tests": 

328 file_path = "/".join(class_parts[:-1]) + ".py" 

329 node_id = f"{class_name}::{test_name}" if class_name else test_name 

330 

331 # Check for failure 

332 failure = testcase.find("failure") 

333 if failure is not None: 

334 message = failure.text or failure.get("message") or "" 

335 issues.append( 

336 PytestIssue( 

337 file=file_path, 

338 line=line, 

339 test_name=test_name, 

340 message=message, 

341 test_status="FAILED", 

342 duration=duration, 

343 node_id=node_id, 

344 ), 

345 ) 

346 

347 # Check for error 

348 error = testcase.find("error") 

349 if error is not None: 

350 message = error.text or error.get("message") or "" 

351 issues.append( 

352 PytestIssue( 

353 file=file_path, 

354 line=line, 

355 test_name=test_name, 

356 message=message, 

357 test_status="ERROR", 

358 duration=duration, 

359 node_id=node_id, 

360 ), 

361 ) 

362 

363 # Check for skip 

364 skip = testcase.find("skipped") 

365 if skip is not None: 

366 message = skip.text or skip.get("message") or "" 

367 # Clean up skip message by removing file path prefix if present 

368 # Format is typically: "/path/to/file.py:line: actual message" 

369 if message and ":" in message: 

370 # Find the first colon after a file path pattern 

371 parts = message.split(":") 

372 if ( 

373 len(parts) >= 3 

374 and parts[0].startswith("/") 

375 and parts[0].endswith(".py") 

376 ): 

377 # Remove file path and line number, keep only the actual reason 

378 message = ":".join(parts[2:]).lstrip() 

379 

380 issues.append( 

381 PytestIssue( 

382 file=file_path, 

383 line=line, 

384 test_name=test_name, 

385 message=message, 

386 test_status="SKIPPED", 

387 duration=duration, 

388 node_id=node_id, 

389 ), 

390 ) 

391 

392 except ElementTree.ParseError as e: 

393 from loguru import logger 

394 

395 logger.debug(f"Failed to parse pytest JUnit XML output: {e}") 

396 

397 return issues