Coverage for lintro / tools / definitions / ruff.py: 97%

92 statements  

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

1"""Ruff tool definition. 

2 

3Ruff is an extremely fast Python linter and code formatter written in Rust. 

4It can replace multiple Python tools like flake8, black, isort, and more. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import os 

11import subprocess # nosec B404 - subprocess used safely to resolve ruff rule names 

12from dataclasses import dataclass, field 

13from typing import Any 

14 

15from loguru import logger 

16 

17from lintro.enums.doc_url_template import DocUrlTemplate 

18from lintro.enums.tool_type import ToolType 

19from lintro.models.core.tool_result import ToolResult 

20from lintro.plugins.base import BaseToolPlugin 

21from lintro.plugins.protocol import ToolDefinition 

22from lintro.plugins.registry import register_tool 

23from lintro.tools.core.option_validators import ( 

24 filter_none_options, 

25 normalize_str_or_list, 

26 validate_bool, 

27 validate_positive_int, 

28 validate_str, 

29) 

30from lintro.utils.config import load_ruff_config 

31from lintro.utils.path_utils import load_lintro_ignore 

32 

33# Constants for Ruff configuration 

34RUFF_DEFAULT_TIMEOUT: int = 30 

35RUFF_DEFAULT_PRIORITY: int = 85 

36RUFF_FILE_PATTERNS: list[str] = ["*.py", "*.pyi"] 

37RUFF_OUTPUT_FORMAT: str = "json" 

38RUFF_TEST_MODE_ENV: str = "LINTRO_TEST_MODE" 

39RUFF_TEST_MODE_VALUE: str = "1" 

40 

41 

42@register_tool 

43@dataclass 

44class RuffPlugin(BaseToolPlugin): 

45 """Ruff Python linter and formatter plugin. 

46 

47 This plugin integrates Ruff with Lintro for linting and formatting 

48 Python files. 

49 """ 

50 

51 _rule_name_cache: dict[str, str | None] = field( 

52 default_factory=dict, 

53 repr=False, 

54 ) 

55 

56 @property 

57 def definition(self) -> ToolDefinition: 

58 """Return the tool definition. 

59 

60 Returns: 

61 ToolDefinition containing tool metadata. 

62 """ 

63 return ToolDefinition( 

64 name="ruff", 

65 description="Fast Python linter and formatter replacing multiple tools", 

66 can_fix=True, 

67 tool_type=ToolType.LINTER | ToolType.FORMATTER, 

68 file_patterns=RUFF_FILE_PATTERNS, 

69 priority=RUFF_DEFAULT_PRIORITY, 

70 conflicts_with=[], 

71 native_configs=["pyproject.toml", "ruff.toml", ".ruff.toml"], 

72 version_command=["ruff", "--version"], 

73 min_version="0.14.0", 

74 default_options={ 

75 "timeout": RUFF_DEFAULT_TIMEOUT, 

76 "select": None, 

77 "ignore": None, 

78 "extend_select": None, 

79 "extend_ignore": None, 

80 "line_length": None, 

81 "target_version": None, 

82 "fix_only": False, 

83 "unsafe_fixes": False, 

84 "show_fixes": False, 

85 "format_check": True, 

86 "format": True, 

87 "lint_fix": True, 

88 }, 

89 default_timeout=RUFF_DEFAULT_TIMEOUT, 

90 ) 

91 

92 def __post_init__(self) -> None: 

93 """Initialize the tool with configuration from pyproject.toml.""" 

94 super().__post_init__() 

95 

96 # Skip config loading in test mode 

97 if os.environ.get(RUFF_TEST_MODE_ENV) != RUFF_TEST_MODE_VALUE: 

98 ruff_config = load_ruff_config() 

99 lintro_ignore_patterns = load_lintro_ignore() 

100 

101 # Update exclude patterns 

102 if "exclude" in ruff_config: 

103 self.exclude_patterns.extend(ruff_config["exclude"]) 

104 if lintro_ignore_patterns: 

105 self.exclude_patterns.extend(lintro_ignore_patterns) 

106 

107 # Update options from configuration 

108 for key in ( 

109 "line_length", 

110 "target_version", 

111 "select", 

112 "ignore", 

113 "unsafe_fixes", 

114 ): 

115 if key in ruff_config: 

116 self.options[key] = ruff_config[key] 

117 

118 # Allow environment variable override for unsafe fixes 

119 env_unsafe_fixes = os.environ.get("RUFF_UNSAFE_FIXES", "").lower() 

120 if env_unsafe_fixes in ("true", "1", "yes", "on"): 

121 self.options["unsafe_fixes"] = True 

122 

123 def set_options( 

124 self, 

125 select: list[str] | None = None, 

126 ignore: list[str] | None = None, 

127 extend_select: list[str] | None = None, 

128 extend_ignore: list[str] | None = None, 

129 line_length: int | None = None, 

130 target_version: str | None = None, 

131 fix_only: bool | None = None, 

132 unsafe_fixes: bool | None = None, 

133 show_fixes: bool | None = None, 

134 format: bool | None = None, 

135 lint_fix: bool | None = None, 

136 format_check: bool | None = None, 

137 **kwargs: Any, 

138 ) -> None: 

139 """Set Ruff-specific options. 

140 

141 Args: 

142 select: Rules to enable. 

143 ignore: Rules to ignore. 

144 extend_select: Additional rules to enable. 

145 extend_ignore: Additional rules to ignore. 

146 line_length: Line length limit. 

147 target_version: Python version target. 

148 fix_only: Only apply fixes, don't report remaining issues. 

149 unsafe_fixes: Include unsafe fixes. 

150 show_fixes: Show enumeration of fixes applied. 

151 format: Whether to run `ruff format` during fix. 

152 lint_fix: Whether to run `ruff check --fix` during fix. 

153 format_check: Whether to run `ruff format --check` in check. 

154 **kwargs: Other tool options. 

155 """ 

156 # Normalize string-or-list parameters 

157 select = normalize_str_or_list(select, "select") 

158 ignore = normalize_str_or_list(ignore, "ignore") 

159 extend_select = normalize_str_or_list(extend_select, "extend_select") 

160 extend_ignore = normalize_str_or_list(extend_ignore, "extend_ignore") 

161 

162 # Validate types 

163 validate_positive_int(line_length, "line_length") 

164 validate_str(target_version, "target_version") 

165 validate_bool(fix_only, "fix_only") 

166 validate_bool(unsafe_fixes, "unsafe_fixes") 

167 validate_bool(show_fixes, "show_fixes") 

168 validate_bool(format, "format") 

169 validate_bool(lint_fix, "lint_fix") 

170 validate_bool(format_check, "format_check") 

171 

172 options = filter_none_options( 

173 select=select, 

174 ignore=ignore, 

175 extend_select=extend_select, 

176 extend_ignore=extend_ignore, 

177 line_length=line_length, 

178 target_version=target_version, 

179 fix_only=fix_only, 

180 unsafe_fixes=unsafe_fixes, 

181 show_fixes=show_fixes, 

182 format=format, 

183 lint_fix=lint_fix, 

184 format_check=format_check, 

185 ) 

186 super().set_options(**options, **kwargs) 

187 

188 def _resolve_rule_name(self, code: str) -> str | None: 

189 """Resolve a rule code to its slug via ``ruff rule``. 

190 

191 Results are cached per-session so the CLI is invoked at most once 

192 per unique code. 

193 

194 Args: 

195 code: Ruff rule code (e.g., "E501"). 

196 

197 Returns: 

198 Rule name slug (e.g., "line-too-long"), or None on failure. 

199 """ 

200 if code in self._rule_name_cache: 

201 return self._rule_name_cache[code] 

202 

203 try: 

204 cmd = self._get_executable_command(tool_name="ruff") 

205 cmd.extend(["rule", "--output-format", "json", code]) 

206 result = subprocess.run( # nosec B603 

207 cmd, 

208 capture_output=True, 

209 text=True, 

210 timeout=5, 

211 check=False, 

212 ) 

213 if result.returncode == 0 and result.stdout: 

214 data = json.loads(result.stdout) 

215 name: str | None = data.get("name") 

216 self._rule_name_cache[code] = name 

217 return name 

218 except ( 

219 subprocess.TimeoutExpired, 

220 json.JSONDecodeError, 

221 OSError, 

222 ): 

223 logger.debug("Failed to resolve ruff rule name for {}", code) 

224 

225 self._rule_name_cache[code] = None 

226 return None 

227 

228 def doc_url(self, code: str) -> str | None: 

229 """Return Ruff documentation URL for the given rule code. 

230 

231 Resolves the rule name slug via ``ruff rule`` so the URL points 

232 to the correct page (e.g., ``line-too-long`` instead of ``E501``). 

233 

234 Args: 

235 code: Ruff rule code (e.g., "E501", "F401"). 

236 

237 Returns: 

238 URL to the Ruff rule documentation, or None if code is empty 

239 or the rule name cannot be resolved. 

240 """ 

241 if not code: 

242 return None 

243 name = self._resolve_rule_name(code) 

244 if name: 

245 return DocUrlTemplate.RUFF.format(code=name) 

246 return None 

247 

248 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

249 """Check files with Ruff. 

250 

251 Args: 

252 paths: List of file or directory paths to check. 

253 options: Runtime options that override defaults. 

254 

255 Returns: 

256 ToolResult with check results. 

257 """ 

258 # Apply runtime options to self.options before execution 

259 if options: 

260 self.options.update(options) 

261 

262 from lintro.tools.implementations.ruff.check import execute_ruff_check 

263 

264 return execute_ruff_check(self, paths) 

265 

266 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

267 """Fix issues in files with Ruff. 

268 

269 Args: 

270 paths: List of file or directory paths to fix. 

271 options: Runtime options that override defaults. 

272 

273 Returns: 

274 ToolResult with fix results. 

275 """ 

276 # Apply runtime options to self.options before execution 

277 if options: 

278 self.options.update(options) 

279 

280 from lintro.tools.implementations.ruff.fix import execute_ruff_fix 

281 

282 return execute_ruff_fix(self, paths)