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
« 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.
3This module provides output parsing with automatic format detection.
4"""
6from __future__ import annotations
8from pathlib import Path
9from typing import Any
11from loguru import logger
13from lintro.parsers.pytest.pytest_issue import PytestIssue
14from lintro.parsers.pytest.pytest_parser import parse_pytest_output
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.
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.
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.
36 Returns:
37 list[PytestIssue]: Parsed test failures, errors, and skips.
38 """
39 issues: list[PytestIssue] = []
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")
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 []
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
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
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}")
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
91 # Try to detect output format automatically
92 # Priority: JSON > JUnit XML > Text
93 output_format = "text"
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"
114 # Parse based on detected format
115 issues = parse_pytest_output(output, format=output_format)
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
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
149 return issues