Coverage for lintro / tools / implementations / ruff / check.py: 96%

75 statements  

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

1"""Ruff check execution logic. 

2 

3Functions for running ruff check commands and processing results. 

4""" 

5 

6import os 

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

8from typing import TYPE_CHECKING 

9 

10from loguru import logger 

11 

12from lintro.parsers.ruff.ruff_format_issue import RuffFormatIssue 

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 run_subprocess_with_timeout, 

21) 

22from lintro.utils.path_filtering import walk_files_with_excludes 

23 

24if TYPE_CHECKING: 

25 from lintro.models.core.tool_result import ToolResult 

26 from lintro.tools.definitions.ruff import RuffPlugin 

27 

28# Default timeout for Ruff operations 

29RUFF_DEFAULT_TIMEOUT: int = 30 

30 

31 

32def execute_ruff_check( 

33 tool: "RuffPlugin", 

34 paths: list[str], 

35) -> "ToolResult": 

36 """Execute ruff check command and process results. 

37 

38 Args: 

39 tool: RuffTool instance 

40 paths: list[str]: List of file or directory paths to check. 

41 

42 Returns: 

43 ToolResult: ToolResult instance. 

44 """ 

45 from lintro.models.core.tool_result import ToolResult 

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

47 build_ruff_check_command, 

48 build_ruff_format_command, 

49 ) 

50 

51 # Check version requirements 

52 version_result = tool._verify_tool_version() 

53 if version_result is not None: 

54 return version_result 

55 

56 tool._validate_paths(paths=paths) 

57 if not paths: 

58 return ToolResult( 

59 name=tool.definition.name, 

60 success=True, 

61 output="No files to check.", 

62 issues_count=0, 

63 ) 

64 

65 # Use shared utility for file discovery 

66 python_files: list[str] = walk_files_with_excludes( 

67 paths=paths, 

68 file_patterns=tool.definition.file_patterns, 

69 exclude_patterns=tool.exclude_patterns, 

70 include_venv=tool.include_venv, 

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

72 tool_name="ruff", 

73 ) 

74 

75 if not python_files: 

76 return ToolResult( 

77 name=tool.definition.name, 

78 success=True, 

79 output="No Python files found to check.", 

80 issues_count=0, 

81 ) 

82 

83 # Ensure Ruff discovers the correct configuration by setting the 

84 # working directory to the common parent of the target files and by 

85 # passing file paths relative to that directory. 

86 cwd: str | None = tool._get_cwd(paths=python_files) 

87 rel_files: list[str] = [] 

88 for f in python_files: 

89 if cwd: 

90 try: 

91 # Try to get relative path; may fail on Windows 

92 # if paths are on different drives 

93 rel_path = os.path.relpath(f, cwd) 

94 rel_files.append(rel_path) 

95 except ValueError: 

96 # Paths are on different drives (Windows) or other error 

97 # - use absolute path 

98 rel_files.append(os.path.abspath(f)) 

99 else: 

100 # No common directory - use absolute paths 

101 rel_files.append(os.path.abspath(f)) 

102 

103 timeout: int = get_timeout_value(tool, RUFF_DEFAULT_TIMEOUT) 

104 # Lint check 

105 cmd: list[str] = build_ruff_check_command(tool=tool, files=rel_files, fix=False) 

106 success_lint: bool 

107 output_lint: str 

108 try: 

109 success_lint, output_lint = run_subprocess_with_timeout( 

110 tool=tool, 

111 cmd=cmd, 

112 timeout=timeout, 

113 cwd=cwd, 

114 ) 

115 except subprocess.TimeoutExpired: 

116 timeout_result = create_timeout_result( 

117 tool=tool, 

118 timeout=timeout, 

119 cmd=cmd, 

120 ) 

121 return ToolResult( 

122 name=tool.definition.name, 

123 success=timeout_result.success, 

124 output=timeout_result.output, 

125 issues_count=timeout_result.issues_count, 

126 issues=timeout_result.issues, 

127 ) 

128 

129 # Debug logging for CI diagnostics 

130 logger.debug(f"[ruff] check command: {' '.join(cmd)}") 

131 logger.debug(f"[ruff] check success: {success_lint}") 

132 if not success_lint: 

133 # Log full output to debug file only - raw JSON output is parsed and 

134 # formatted into tables, so no need to show it in console warnings 

135 logger.debug(f"[ruff] check full output:\n{output_lint}") 

136 

137 lint_issues = parse_ruff_output(output=output_lint) 

138 lint_issues_count: int = len(lint_issues) 

139 

140 # Optional format check via `format_check` flag 

141 format_issues_count: int = 0 

142 format_files: list[str] = [] 

143 format_issues: list[RuffFormatIssue] = [] 

144 success_format: bool = True # Default to True when format check is skipped 

145 if tool.options.get("format_check", False): 

146 format_cmd: list[str] = build_ruff_format_command( 

147 tool=tool, 

148 files=rel_files, 

149 check_only=True, 

150 ) 

151 output_format: str 

152 try: 

153 success_format, output_format = run_subprocess_with_timeout( 

154 tool=tool, 

155 cmd=format_cmd, 

156 timeout=timeout, 

157 cwd=cwd, 

158 ) 

159 except subprocess.TimeoutExpired: 

160 timeout_result = create_timeout_result( 

161 tool=tool, 

162 timeout=timeout, 

163 cmd=format_cmd, 

164 ) 

165 return ToolResult( 

166 name=tool.definition.name, 

167 success=timeout_result.success, 

168 output=timeout_result.output, 

169 issues_count=lint_issues_count + timeout_result.issues_count, 

170 issues=lint_issues + timeout_result.issues, 

171 ) 

172 

173 # Debug logging for CI diagnostics 

174 logger.debug(f"[ruff] format --check command: {' '.join(format_cmd)}") 

175 logger.debug(f"[ruff] format --check success: {success_format}") 

176 if not success_format: 

177 # Log full output to debug file only - output is parsed and 

178 # formatted into tables, so no need to show it in console warnings 

179 logger.debug(f"[ruff] format check full output:\n{output_format}") 

180 

181 format_files = parse_ruff_format_check_output(output=output_format) 

182 # Normalize files to absolute paths to keep behavior consistent with 

183 # direct CLI calls and stabilize tests that compare exact paths. 

184 normalized_files: list[str] = [] 

185 for file_path in format_files: 

186 if cwd and not os.path.isabs(file_path): 

187 absolute_path = os.path.abspath(os.path.join(cwd, file_path)) 

188 normalized_files.append(absolute_path) 

189 else: 

190 normalized_files.append(file_path) 

191 format_issues_count = len(normalized_files) 

192 format_issues = [RuffFormatIssue(file=file) for file in normalized_files] 

193 

194 # Combine results - respect subprocess exit codes and issue counts 

195 issues_count: int = lint_issues_count + format_issues_count 

196 success: bool = success_lint and success_format and (issues_count == 0) 

197 

198 # Diagnostic logging for the "ERROR" case (subprocess failed but no issues parsed) 

199 if not success and issues_count == 0: 

200 logger.warning( 

201 f"ruff subprocess failed (lint={success_lint}, format={success_format}) " 

202 f"but no issues were parsed - this indicates a ruff execution error", 

203 ) 

204 

205 # Suppress narrative blocks; rely on standardized tables and summary lines 

206 output_summary: str | None = None 

207 

208 # Combine linting and formatting issues for the formatters 

209 all_issues = lint_issues + format_issues 

210 

211 return ToolResult( 

212 name=tool.definition.name, 

213 success=success, 

214 output=output_summary, 

215 issues_count=issues_count, 

216 issues=all_issues, 

217 )