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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Format-specific parsers for pytest output.
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"""
9from __future__ import annotations
11import json
12import re
14from defusedxml import ElementTree
16from lintro.parsers.base_parser import strip_ansi_codes
17from lintro.parsers.pytest.pytest_issue import PytestIssue
20def parse_pytest_json_output(output: str) -> list[PytestIssue]:
21 """Parse pytest JSON output into PytestIssue objects.
23 Args:
24 output: Raw output from pytest with --json-report.
26 Returns:
27 list[PytestIssue]: Parsed test failures, errors, and skips.
28 """
29 issues: list[PytestIssue] = []
31 if not output or output.strip() in ("{}", "[]"):
32 return issues
34 try:
35 data = json.loads(output)
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))
53 except (json.JSONDecodeError, TypeError, KeyError) as e:
54 from loguru import logger
56 logger.debug(f"Failed to parse pytest JSON output: {e}")
58 return issues
61def _parse_json_test_item(test_item: dict[str, object]) -> PytestIssue:
62 """Parse a single test item from JSON output.
64 Args:
65 test_item: Dictionary containing test information.
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 ""
73 line_raw = test_item.get("lineno")
74 line = int(line_raw) if isinstance(line_raw, int) else 0
76 name_raw = test_item.get("name")
77 test_name = name_raw if isinstance(name_raw, str) else ""
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
86 longrepr_raw = test_item.get("longrepr")
87 message = call_longrepr or (longrepr_raw if isinstance(longrepr_raw, str) else "")
89 status_raw = test_item.get("outcome")
90 status = status_raw if isinstance(status_raw, str) else "UNKNOWN"
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
98 node_id_raw = test_item.get("nodeid")
99 node_id: str | None = node_id_raw if isinstance(node_id_raw, str) else ""
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 )
112def parse_pytest_text_output(output: str) -> list[PytestIssue]:
113 """Parse pytest plain text output into PytestIssue objects.
115 Args:
116 output: Raw output from pytest.
118 Returns:
119 list[PytestIssue]: Parsed test failures, errors, and skips.
120 """
121 issues: list[PytestIssue] = []
123 if not output:
124 return issues
126 lines = output.splitlines()
127 current_file = ""
128 current_line = 0
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+(.+)$")
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+\((.+)\)$")
144 for line in lines:
145 # Strip ANSI color codes for stable parsing
146 line = strip_ansi_codes(line).strip()
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
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
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
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
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
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
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
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"
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 )
286 return issues
289def parse_pytest_junit_xml(output: str) -> list[PytestIssue]:
290 """Parse pytest JUnit XML output into PytestIssue objects.
292 Args:
293 output: Raw output from pytest with --junitxml.
295 Returns:
296 list[PytestIssue]: Parsed test failures, errors, and skips.
297 """
298 issues: list[PytestIssue] = []
300 if not output:
301 return issues
303 try:
304 root = ElementTree.fromstring(output)
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
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 )
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 )
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()
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 )
392 except ElementTree.ParseError as e:
393 from loguru import logger
395 logger.debug(f"Failed to parse pytest JUnit XML output: {e}")
397 return issues