Coverage for lintro / tools / implementations / pytest / output_parsers.py: 53%

59 statements  

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

1"""Output parsing with format detection and fallback for pytest. 

2 

3This module provides output parsing with automatic format detection. 

4""" 

5 

6from __future__ import annotations 

7 

8from pathlib import Path 

9from typing import Any 

10 

11from loguru import logger 

12 

13from lintro.parsers.pytest.pytest_issue import PytestIssue 

14from lintro.parsers.pytest.pytest_parser import parse_pytest_output 

15 

16 

17def parse_pytest_output_with_fallback( 

18 output: str, 

19 return_code: int, 

20 options: dict[str, Any], 

21 subprocess_start_time: float | None = None, 

22) -> list[PytestIssue]: 

23 """Parse pytest output into issues with format detection and fallback. 

24 

25 Prioritizes JSON format when available, then JUnit XML, then falls back to text. 

26 Validates parsed output structure to ensure reliability. 

27 Always tries to parse JUnit XML file if available to capture skipped tests. 

28 

29 Args: 

30 output: Raw output from pytest. 

31 return_code: Return code from pytest. 

32 options: Options dictionary. 

33 subprocess_start_time: Optional Unix timestamp when subprocess started. 

34 If provided, only JUnit XML files modified after this time will be read. 

35 

36 Returns: 

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

38 """ 

39 issues: list[PytestIssue] = [] 

40 

41 # Try to parse JUnit XML file if it exists and was explicitly requested 

42 # This captures all test results including skips when using JUnit XML format 

43 # But only if the output we're parsing is not already JUnit XML 

44 # AND we're not in JSON mode (prioritize JSON over JUnit XML) 

45 # Check this BEFORE early return to ensure JUnit XML parsing happens even 

46 # when output is empty (e.g., quiet mode or redirected output) 

47 junitxml_path = None 

48 if ( 

49 options.get("junitxml") 

50 and (not output or not output.strip().startswith("<?xml")) 

51 and not options.get("json_report", False) 

52 ): 

53 junitxml_path = options.get("junitxml") 

54 

55 # Early return only if output is empty AND no JUnit XML file to parse 

56 if not output and not (junitxml_path and Path(junitxml_path).exists()): 

57 return [] 

58 

59 if junitxml_path and Path(junitxml_path).exists(): 

60 # Only read the file if it was modified after subprocess started 

61 # This prevents reading stale files from previous test runs 

62 junitxml_file = Path(junitxml_path) 

63 file_mtime = junitxml_file.stat().st_mtime 

64 should_read = True 

65 

66 if subprocess_start_time is not None and file_mtime < subprocess_start_time: 

67 logger.debug( 

68 f"Skipping stale JUnit XML file {junitxml_path} " 

69 f"(modified before subprocess started)", 

70 ) 

71 should_read = False 

72 

73 if should_read: 

74 try: 

75 with open(junitxml_path, encoding="utf-8") as f: 

76 junit_content = f.read() 

77 junit_issues = parse_pytest_output(junit_content, format="junit") 

78 if junit_issues: 

79 issues.extend(junit_issues) 

80 logger.debug( 

81 f"Parsed {len(junit_issues)} issues from JUnit XML file", 

82 ) 

83 except OSError as e: 

84 logger.debug(f"Failed to read JUnit XML file {junitxml_path}: {e}") 

85 

86 # If we already have issues from JUnit XML, return them 

87 # Otherwise, fall back to parsing the output 

88 if issues: 

89 return issues 

90 

91 # Try to detect output format automatically 

92 # Priority: JSON > JUnit XML > Text 

93 output_format = "text" 

94 

95 # Check for JSON format (pytest-json-report) 

96 if options.get("json_report", False): 

97 output_format = "json" 

98 elif options.get("junitxml"): 

99 output_format = "junit" 

100 else: 

101 # Auto-detect format from output content 

102 # Check for JSON report file reference or JSON content 

103 if "pytest-report.json" in output or ( 

104 output.strip().startswith("{") and "test_reports" in output 

105 ): 

106 output_format = "json" 

107 # Check for JUnit XML structure 

108 elif output.strip().startswith("<?xml") and "<testsuite" in output: 

109 output_format = "junit" 

110 # Default to text parsing 

111 else: 

112 output_format = "text" 

113 

114 # Parse based on detected format 

115 issues = parse_pytest_output(output, format=output_format) 

116 

117 # Validate parsed output structure 

118 if not isinstance(issues, list): 

119 logger.warning( 

120 f"Parser returned unexpected type: {type(issues)}, " 

121 "falling back to text parsing", 

122 ) 

123 issues = [] 

124 else: 

125 # Validate that all items are PytestIssue instances 

126 validated_issues = [] 

127 for issue in issues: 

128 if isinstance(issue, PytestIssue): 

129 validated_issues.append(issue) 

130 else: 

131 logger.warning( 

132 f"Skipping invalid issue type: {type(issue)}", 

133 ) 

134 issues = validated_issues 

135 

136 # If no issues found but return code indicates failure, try text parsing 

137 if not issues and return_code != 0 and output_format != "text": 

138 logger.debug( 

139 f"No issues parsed from {output_format} format, " 

140 "trying text parsing fallback", 

141 ) 

142 fallback_issues = parse_pytest_output(output, format="text") 

143 if fallback_issues: 

144 logger.info( 

145 f"Fallback text parsing found {len(fallback_issues)} issues", 

146 ) 

147 issues = fallback_issues 

148 

149 return issues