Coverage for lintro / tools / implementations / ruff / fix.py: 99%

112 statements  

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

1"""Ruff fix execution logic. 

2 

3Functions for running ruff fix commands and processing results. 

4""" 

5 

6import subprocess # nosec B404 - subprocess used safely to execute ruff commands with controlled input 

7from collections.abc import Generator 

8from contextlib import contextmanager 

9from typing import TYPE_CHECKING 

10 

11from loguru import logger 

12 

13from lintro.parsers.ruff.ruff_parser import ( 

14 parse_ruff_format_check_output, 

15 parse_ruff_output, 

16) 

17from lintro.tools.core.timeout_utils import ( 

18 create_timeout_result, 

19 get_timeout_value, 

20) 

21from lintro.utils.path_filtering import walk_files_with_excludes 

22 

23if TYPE_CHECKING: 

24 from lintro.models.core.tool_result import ToolResult 

25 from lintro.tools.definitions.ruff import RuffPlugin 

26 

27# Constants from tool_ruff.py 

28RUFF_DEFAULT_TIMEOUT: int = 30 

29DEFAULT_REMAINING_ISSUES_DISPLAY: int = 5 

30 

31 

32@contextmanager 

33def _temporary_option( 

34 tool: "RuffPlugin", 

35 option_key: str, 

36 option_value: object, 

37) -> Generator[None]: 

38 """Context manager for temporarily setting a tool option. 

39 

40 Safely mutates tool.options for the duration of the context, ensuring 

41 the original value is always restored even if an exception occurs. 

42 

43 Args: 

44 tool: RuffTool instance whose options will be temporarily modified. 

45 option_key: Key of the option to temporarily set. 

46 option_value: Value to temporarily set for the option. 

47 

48 Yields: 

49 None: The context manager yields control after setting the option. 

50 

51 Example: 

52 >>> with _temporary_option(tool, "unsafe_fixes", True): 

53 ... # tool.options["unsafe_fixes"] is now True 

54 ... build_command(tool) 

55 >>> # tool.options["unsafe_fixes"] is restored to original value 

56 """ 

57 # Check if key existed before (distinguishes None value from missing key) 

58 key_existed = option_key in tool.options 

59 original_value = tool.options.get(option_key) 

60 try: 

61 tool.options[option_key] = option_value 

62 yield 

63 finally: 

64 # Always restore the original state, even if an exception occurs 

65 if key_existed: 

66 tool.options[option_key] = original_value 

67 elif option_key in tool.options: 

68 # Remove the key if it wasn't originally present 

69 del tool.options[option_key] 

70 

71 

72def execute_ruff_fix( 

73 tool: "RuffPlugin", 

74 paths: list[str], 

75) -> "ToolResult": 

76 """Execute ruff fix command and process results. 

77 

78 Args: 

79 tool: RuffTool instance 

80 paths: list[str]: List of file or directory paths to fix. 

81 

82 Returns: 

83 ToolResult: ToolResult instance. 

84 """ 

85 from lintro.models.core.tool_result import ToolResult 

86 from lintro.tools.implementations.ruff.commands import ( 

87 build_ruff_check_command, 

88 build_ruff_format_command, 

89 ) 

90 

91 # Check version requirements 

92 version_result = tool._verify_tool_version() 

93 if version_result is not None: 

94 return version_result 

95 

96 tool._validate_paths(paths=paths) 

97 if not paths: 

98 return ToolResult( 

99 name=tool.definition.name, 

100 success=True, 

101 output="No files to fix.", 

102 issues_count=0, 

103 ) 

104 

105 # Use shared utility for file discovery 

106 python_files: list[str] = walk_files_with_excludes( 

107 paths=paths, 

108 file_patterns=tool.definition.file_patterns, 

109 exclude_patterns=tool.exclude_patterns, 

110 include_venv=tool.include_venv, 

111 incremental=bool(tool.options.get("incremental", False)), 

112 tool_name="ruff", 

113 ) 

114 

115 if not python_files: 

116 return ToolResult( 

117 name=tool.definition.name, 

118 success=True, 

119 output="No Python files found to fix.", 

120 issues_count=0, 

121 ) 

122 

123 timeout: int = get_timeout_value(tool, RUFF_DEFAULT_TIMEOUT) 

124 overall_success: bool = True 

125 

126 # Track unsafe fixes for internal decisioning; do not emit as user-facing noise 

127 unsafe_fixes_enabled: bool = bool(tool.options.get("unsafe_fixes", False)) 

128 

129 # First, count issues before fixing 

130 cmd_check: list[str] = build_ruff_check_command( 

131 tool=tool, 

132 files=python_files, 

133 fix=False, 

134 ) 

135 success_check: bool = False 

136 output_check: str = "" 

137 try: 

138 success_check, output_check = tool._run_subprocess( 

139 cmd=cmd_check, 

140 timeout=timeout, 

141 ) 

142 except subprocess.TimeoutExpired: 

143 timeout_result = create_timeout_result( 

144 tool=tool, 

145 timeout=timeout, 

146 cmd=cmd_check, 

147 ) 

148 return ToolResult( 

149 name=tool.definition.name, 

150 success=timeout_result.success, 

151 output=timeout_result.output, 

152 issues_count=timeout_result.issues_count, 

153 issues=timeout_result.issues, 

154 initial_issues_count=None, 

155 fixed_issues_count=None, 

156 remaining_issues_count=None, 

157 ) 

158 initial_issues = parse_ruff_output(output=output_check) 

159 initial_count: int = len(initial_issues) 

160 

161 # Also check formatting issues before fixing 

162 initial_format_count: int = 0 

163 format_files: list[str] = [] 

164 if tool.options.get("format", False): 

165 format_cmd_check: list[str] = build_ruff_format_command( 

166 tool=tool, 

167 files=python_files, 

168 check_only=True, 

169 ) 

170 success_format_check: bool = False 

171 output_format_check: str = "" 

172 try: 

173 success_format_check, output_format_check = tool._run_subprocess( 

174 cmd=format_cmd_check, 

175 timeout=timeout, 

176 ) 

177 except subprocess.TimeoutExpired: 

178 timeout_msg = ( 

179 f"Ruff execution timed out ({timeout}s limit exceeded).\n\n" 

180 "This may indicate:\n" 

181 " - Large codebase taking too long to process\n" 

182 " - Need to increase timeout via --tool-options ruff:timeout=N" 

183 ) 

184 return ToolResult( 

185 name=tool.definition.name, 

186 success=False, 

187 output=timeout_msg, 

188 issues_count=1, # Count timeout as execution failure 

189 # Include any lint issues found before timeout 

190 issues=initial_issues, 

191 initial_issues_count=initial_count, 

192 fixed_issues_count=0, 

193 remaining_issues_count=1, 

194 ) 

195 format_files = parse_ruff_format_check_output(output=output_format_check) 

196 initial_format_count = len(format_files) 

197 

198 # Track initial totals separately for accurate fixed/remaining math 

199 total_initial_count: int = initial_count + initial_format_count 

200 

201 # Optionally run ruff check --fix (lint fixes) 

202 remaining_issues = [] 

203 remaining_count = 0 

204 success: bool = True # Default to True when lint_fix is disabled 

205 if tool.options.get("lint_fix", True): 

206 cmd: list[str] = build_ruff_check_command( 

207 tool=tool, 

208 files=python_files, 

209 fix=True, 

210 ) 

211 output: str = "" 

212 try: 

213 success, output = tool._run_subprocess(cmd=cmd, timeout=timeout) 

214 except subprocess.TimeoutExpired: 

215 timeout_msg = ( 

216 f"Ruff execution timed out ({timeout}s limit exceeded).\n\n" 

217 "This may indicate:\n" 

218 " - Large codebase taking too long to process\n" 

219 " - Need to increase timeout via --tool-options ruff:timeout=N" 

220 ) 

221 return ToolResult( 

222 name=tool.definition.name, 

223 success=False, 

224 output=timeout_msg, 

225 issues_count=1, # Count timeout as execution failure 

226 issues=initial_issues, # Include initial issues found 

227 initial_issues_count=total_initial_count, 

228 fixed_issues_count=0, 

229 remaining_issues_count=1, 

230 ) 

231 remaining_issues = parse_ruff_output(output=output) 

232 remaining_count = len(remaining_issues) 

233 

234 # Compute fixed lint issues by diffing initial vs remaining (internal only) 

235 # Not used for display; summary counts reflect totals. 

236 

237 # Calculate how many lint issues were actually fixed 

238 fixed_lint_count: int = max(0, initial_count - remaining_count) 

239 fixed_count: int = fixed_lint_count 

240 

241 # If there are remaining issues, check if any are fixable with unsafe fixes 

242 # If unsafe fixes are disabled, check if any remaining issues are 

243 # fixable with unsafe fixes 

244 if remaining_count > 0 and not unsafe_fixes_enabled: 

245 # Try running ruff with unsafe fixes in dry-run mode to see if it 

246 # would fix more 

247 # Use context manager to safely temporarily enable unsafe_fixes 

248 remaining_unsafe = remaining_issues 

249 success_unsafe: bool = False 

250 output_unsafe: str = "" 

251 with _temporary_option(tool, "unsafe_fixes", True): 

252 # Build command with unsafe_fixes temporarily enabled 

253 cmd_unsafe: list[str] = build_ruff_check_command( 

254 tool=tool, 

255 files=python_files, 

256 fix=True, 

257 ) 

258 # Command is built, option will be restored by context manager 

259 # Run the command (option already restored) 

260 try: 

261 success_unsafe, output_unsafe = tool._run_subprocess( 

262 cmd=cmd_unsafe, 

263 timeout=timeout, 

264 ) 

265 except subprocess.TimeoutExpired: 

266 # If unsafe check times out, just continue with current results 

267 # Don't fail the entire operation for this optional check 

268 pass 

269 else: 

270 remaining_unsafe = parse_ruff_output(output=output_unsafe) 

271 if len(remaining_unsafe) < remaining_count: 

272 logger.warning( 

273 "Some remaining issues could be fixed by enabling unsafe " 

274 "fixes (use --tool-options ruff:unsafe_fixes=True)", 

275 ) 

276 # Log remaining issues for debugging (if verbose) 

277 # Note: Issue details are already included in the ToolResult.issues list 

278 

279 if not (success and remaining_count == 0): 

280 overall_success = False 

281 

282 # Run ruff format if enabled (default: True) 

283 if tool.options.get("format", False): 

284 format_cmd: list[str] = build_ruff_format_command( 

285 tool=tool, 

286 files=python_files, 

287 check_only=False, 

288 ) 

289 format_success: bool = False 

290 format_output: str = "" 

291 try: 

292 format_success, format_output = tool._run_subprocess( 

293 cmd=format_cmd, 

294 timeout=timeout, 

295 ) 

296 # For ruff format, exit code 1 means files were formatted (success) 

297 # Only consider it a failure if there were no initial format issues 

298 if not format_success and initial_format_count > 0: 

299 format_success = True 

300 except subprocess.TimeoutExpired: 

301 timeout_msg = ( 

302 f"Ruff execution timed out ({timeout}s limit exceeded).\n\n" 

303 "This may indicate:\n" 

304 " - Large codebase taking too long to process\n" 

305 " - Need to increase timeout via --tool-options ruff:timeout=N" 

306 ) 

307 return ToolResult( 

308 name=tool.definition.name, 

309 success=False, 

310 output=timeout_msg, 

311 issues_count=1, # Count timeout as execution failure 

312 issues=remaining_issues, # Include any issues found before timeout 

313 initial_issues_count=total_initial_count, 

314 fixed_issues_count=fixed_lint_count, 

315 remaining_issues_count=1, 

316 ) 

317 # Formatting fixes are counted separately from lint fixes 

318 if initial_format_count > 0: 

319 fixed_count = fixed_lint_count + initial_format_count 

320 # Only consider formatting failure if there are actual formatting 

321 # issues. Don't fail the overall operation just because formatting 

322 # failed when there are no issues 

323 if not format_success and total_initial_count > 0: 

324 overall_success = False 

325 

326 # Build concise, unified summary output for fmt runs 

327 summary_lines: list[str] = [] 

328 if fixed_count > 0: 

329 summary_lines.append(f"Fixed {fixed_count} issue(s)") 

330 if remaining_count > 0: 

331 summary_lines.append( 

332 f"Found {remaining_count} issue(s) that cannot be auto-fixed", 

333 ) 

334 final_output: str = ( 

335 "\n".join(summary_lines) if summary_lines else "No fixes applied." 

336 ) 

337 

338 return ToolResult( 

339 name=tool.definition.name, 

340 success=overall_success, 

341 output=final_output, 

342 # For fix operations, issues_count represents remaining issues 

343 # that couldn't be fixed 

344 issues_count=remaining_count, 

345 # Only include remaining issues that couldn't be fixed 

346 # (not successful format operations) 

347 issues=remaining_issues, 

348 initial_issues_count=total_initial_count, 

349 fixed_issues_count=fixed_count, 

350 remaining_issues_count=remaining_count, 

351 # Store pre-fix issues so the display layer can show what was fixed 

352 initial_issues=initial_issues if initial_issues else None, 

353 )