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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Ruff tool definition.
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"""
7from __future__ import annotations
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
15from loguru import logger
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
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"
42@register_tool
43@dataclass
44class RuffPlugin(BaseToolPlugin):
45 """Ruff Python linter and formatter plugin.
47 This plugin integrates Ruff with Lintro for linting and formatting
48 Python files.
49 """
51 _rule_name_cache: dict[str, str | None] = field(
52 default_factory=dict,
53 repr=False,
54 )
56 @property
57 def definition(self) -> ToolDefinition:
58 """Return the tool definition.
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 )
92 def __post_init__(self) -> None:
93 """Initialize the tool with configuration from pyproject.toml."""
94 super().__post_init__()
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()
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)
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]
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
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.
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")
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")
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)
188 def _resolve_rule_name(self, code: str) -> str | None:
189 """Resolve a rule code to its slug via ``ruff rule``.
191 Results are cached per-session so the CLI is invoked at most once
192 per unique code.
194 Args:
195 code: Ruff rule code (e.g., "E501").
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]
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)
225 self._rule_name_cache[code] = None
226 return None
228 def doc_url(self, code: str) -> str | None:
229 """Return Ruff documentation URL for the given rule code.
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``).
234 Args:
235 code: Ruff rule code (e.g., "E501", "F401").
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
248 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
249 """Check files with Ruff.
251 Args:
252 paths: List of file or directory paths to check.
253 options: Runtime options that override defaults.
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)
262 from lintro.tools.implementations.ruff.check import execute_ruff_check
264 return execute_ruff_check(self, paths)
266 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
267 """Fix issues in files with Ruff.
269 Args:
270 paths: List of file or directory paths to fix.
271 options: Runtime options that override defaults.
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)
280 from lintro.tools.implementations.ruff.fix import execute_ruff_fix
282 return execute_ruff_fix(self, paths)