Coverage for lintro / tools / implementations / pytest / output.py: 71%

79 statements  

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

1"""Output processing functions for pytest tool. 

2 

3This module contains output parsing, summary extraction, performance warnings, 

4and flaky test detection logic extracted from PytestTool to improve 

5maintainability and reduce file size. 

6""" 

7 

8from __future__ import annotations 

9 

10import configparser 

11import os 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any 

14 

15from loguru import logger 

16 

17if TYPE_CHECKING: 

18 from lintro.tools.definitions.pytest import PytestPlugin 

19 

20from lintro.tools.implementations.pytest.collection import ( 

21 PYTEST_FLAKY_CACHE_FILE, 

22 PYTEST_FLAKY_FAILURE_RATE, 

23 PYTEST_FLAKY_MIN_RUNS, 

24) 

25 

26PYTEST_SLOW_TEST_THRESHOLD: float = 1.0 

27PYTEST_TOTAL_TIME_WARNING: float = 60.0 

28 

29# Path to flaky test history file - use constant from collection.py 

30FLAKY_TEST_HISTORY_PATH = Path(PYTEST_FLAKY_CACHE_FILE) 

31 

32 

33def detect_flaky_tests( 

34 history: dict[str, dict[str, int]], 

35 min_runs: int = PYTEST_FLAKY_MIN_RUNS, 

36 failure_rate: float = PYTEST_FLAKY_FAILURE_RATE, 

37) -> list[tuple[str, float]]: 

38 """Detect flaky tests from history. 

39 

40 A test is considered flaky if: 

41 - It has been run at least min_runs times 

42 - It has failures but not 100% failure rate 

43 - Failure rate >= failure_rate threshold 

44 

45 Args: 

46 history: Test history dictionary. 

47 min_runs: Minimum number of runs before considering flaky. 

48 failure_rate: Minimum failure rate to consider flaky (0.0 to 1.0). 

49 

50 Returns: 

51 list[tuple[str, float]]: List of (test_node_id, failure_rate) tuples. 

52 """ 

53 flaky_tests: list[tuple[str, float]] = [] 

54 

55 for node_id, counts in history.items(): 

56 total_runs = ( 

57 counts.get("passed", 0) + counts.get("failed", 0) + counts.get("error", 0) 

58 ) 

59 

60 if total_runs < min_runs: 

61 continue 

62 

63 failed_count = counts.get("failed", 0) + counts.get("error", 0) 

64 current_failure_rate = failed_count / total_runs 

65 

66 # Consider flaky if: 

67 # 1. Has failures (failure_rate > 0) 

68 # 2. Not always failing (failure_rate < 1.0) 

69 # 3. Failure rate >= threshold 

70 if 0 < current_failure_rate < 1.0 and current_failure_rate >= failure_rate: 

71 flaky_tests.append((node_id, current_failure_rate)) 

72 

73 # Sort by failure rate descending 

74 flaky_tests.sort(key=lambda x: x[1], reverse=True) 

75 return flaky_tests 

76 

77 

78# Module-level cache for pytest config to avoid repeated file parsing 

79_PYTEST_CONFIG_CACHE: dict[tuple[str, float, float], dict[str, Any]] = {} 

80 

81 

82def clear_pytest_config_cache() -> None: 

83 """Clear the pytest config cache. 

84 

85 This function is primarily intended for testing to ensure 

86 config files are re-read when needed. 

87 """ 

88 _PYTEST_CONFIG_CACHE.clear() 

89 

90 

91def load_pytest_config() -> dict[str, Any]: 

92 """Load pytest configuration from pyproject.toml or pytest.ini. 

93 

94 Priority order (highest to lowest): 

95 1. pyproject.toml [tool.pytest.ini_options] (pytest convention) 

96 2. pyproject.toml [tool.pytest] (backward compatibility) 

97 3. pytest.ini [pytest] 

98 

99 This function uses caching to avoid repeatedly parsing config files 

100 during the same process run. Cache is keyed by working directory and 

101 file modification times to ensure freshness. 

102 

103 Returns: 

104 dict: Pytest configuration dictionary. 

105 """ 

106 cwd = os.getcwd() 

107 pyproject_path = Path("pyproject.toml") 

108 pytest_ini_path = Path("pytest.ini") 

109 

110 # Create cache key from working directory and file modification times 

111 cache_key = ( 

112 cwd, 

113 pyproject_path.stat().st_mtime if pyproject_path.exists() else 0.0, 

114 pytest_ini_path.stat().st_mtime if pytest_ini_path.exists() else 0.0, 

115 ) 

116 

117 # Return cached result if available 

118 if cache_key in _PYTEST_CONFIG_CACHE: 

119 return _PYTEST_CONFIG_CACHE[cache_key].copy() 

120 

121 config: dict[str, Any] = {} 

122 

123 # Check pyproject.toml first 

124 if pyproject_path.exists(): 

125 try: 

126 import tomllib 

127 

128 with open(pyproject_path, "rb") as f: 

129 pyproject_data = tomllib.load(f) 

130 if "tool" in pyproject_data and "pytest" in pyproject_data["tool"]: 

131 pytest_tool_data = pyproject_data["tool"]["pytest"] 

132 # Check for ini_options first (pytest convention) 

133 if ( 

134 isinstance(pytest_tool_data, dict) 

135 and "ini_options" in pytest_tool_data 

136 ): 

137 config = pytest_tool_data["ini_options"] 

138 # Fall back to direct pytest config (backward compatibility) 

139 elif isinstance(pytest_tool_data, dict): 

140 config = pytest_tool_data 

141 except (OSError, KeyError, TypeError, ValueError) as e: 

142 logger.warning( 

143 f"Failed to load pytest configuration from pyproject.toml: {e}", 

144 ) 

145 

146 # Check pytest.ini (lowest priority, updates existing config) 

147 if pytest_ini_path.exists(): 

148 try: 

149 parser = configparser.ConfigParser() 

150 parser.read(pytest_ini_path) 

151 if "pytest" in parser: 

152 ini_config = dict(parser["pytest"]) 

153 # Merge with pyproject.toml having higher priority 

154 for key, value in ini_config.items(): 

155 if key not in config: 

156 config[key] = value 

157 except (OSError, configparser.Error) as e: 

158 logger.warning(f"Failed to load pytest configuration from pytest.ini: {e}") 

159 

160 # Cache the result 

161 _PYTEST_CONFIG_CACHE[cache_key] = config.copy() 

162 return config.copy() 

163 

164 

165def load_file_patterns_from_config( 

166 pytest_config: dict[str, Any], 

167) -> list[str]: 

168 """Load file patterns from pytest configuration. 

169 

170 Args: 

171 pytest_config: Pytest configuration dictionary. 

172 

173 Returns: 

174 list[str]: File patterns from config, or empty list if not configured. 

175 """ 

176 if not pytest_config: 

177 return [] 

178 

179 # Get python_files from config 

180 python_files = pytest_config.get("python_files") 

181 if not python_files: 

182 return [] 

183 

184 # Handle both string and list formats 

185 if isinstance(python_files, str): 

186 # Split on whitespace and commas 

187 patterns = [ 

188 p.strip() for p in python_files.replace(",", " ").split() if p.strip() 

189 ] 

190 return patterns 

191 elif isinstance(python_files, list): 

192 return python_files 

193 else: 

194 logger.warning(f"Unexpected python_files type: {type(python_files)}") 

195 return [] 

196 

197 

198def initialize_pytest_tool_config(tool: PytestPlugin) -> None: 

199 """Initialize pytest tool configuration from config files. 

200 

201 Loads pytest config, file patterns, and default options. 

202 Updates tool._file_patterns_from_config and tool.options. 

203 

204 Args: 

205 tool: PytestPlugin instance to initialize. 

206 """ 

207 # Load pytest configuration 

208 pytest_config = load_pytest_config() 

209 

210 # Load file patterns from config if available 

211 config_file_patterns = load_file_patterns_from_config(pytest_config) 

212 if config_file_patterns: 

213 # Override default patterns with config patterns 

214 tool._file_patterns_from_config = config_file_patterns 

215 

216 # Apply any additional config options from pytest_config 

217 # Merge pytest_config options into tool.options with safe defaults 

218 if pytest_config and "options" in pytest_config: 

219 tool.options.update(pytest_config.get("options", {}))