"""Ruff integration for Python-mode.
This module provides integration with Ruff, a fast Python linter and formatter.
It replaces the previous pylama-based linting system with a single, modern tool.
"""
import json
import os
import subprocess
import tempfile
from typing import Dict, List, Optional, Any
from .environment import env
from .utils import silence_stderr
class RuffError:
"""Represents a Ruff linting error/warning."""
def __init__(self, data: Dict[str, Any]):
"""Initialize from Ruff JSON output."""
self.filename = data.get('filename', '')
self.line = data.get('location', {}).get('row', 1)
self.col = data.get('location', {}).get('column', 1)
self.code = data.get('code', '')
self.message = data.get('message', '')
self.severity = data.get('severity', 'error')
self.rule = data.get('rule', '')
def to_dict(self) -> Dict[str, Any]:
"""Convert to vim-compatible error dictionary."""
return {
'filename': self.filename,
'lnum': self.line,
'col': self.col,
'text': f"{self.code}: {self.message}",
'type': 'E' if self.severity == 'error' else 'W',
'code': self.code,
}
def _get_ruff_executable() -> str:
"""Get the ruff executable path."""
# Try to get from vim configuration first
ruff_path = env.var('g:pymode_ruff_executable', silence=True, default='ruff')
# Verify ruff is available
try:
subprocess.run([ruff_path, '--version'],
capture_output=True, check=True, timeout=5)
return ruff_path
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
env.error("Ruff not found. Please install ruff: pip install ruff")
raise RuntimeError("Ruff executable not found")
def _find_local_ruff_config(file_path: str) -> Optional[str]:
"""Find local Ruff configuration file starting from file's directory.
Ruff searches for config files in this order (highest priority first):
1. .ruff.toml
2. ruff.toml
3. pyproject.toml (with [tool.ruff] section)
Args:
file_path: Path to the Python file being checked
Returns:
Path to the first Ruff config file found, or None if none found
"""
# Start from the file's directory
current_dir = os.path.dirname(os.path.abspath(file_path))
# Config file names in priority order
config_files = ['.ruff.toml', 'ruff.toml', 'pyproject.toml']
# Walk up the directory tree
while True:
# Check for config files in current directory
for config_file in config_files:
config_path = os.path.join(current_dir, config_file)
if os.path.exists(config_path):
# For pyproject.toml, check if it contains [tool.ruff] section
if config_file == 'pyproject.toml':
try:
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
if '[tool.ruff]' in content:
return config_path
except (IOError, UnicodeDecodeError):
# If we can't read it, let Ruff handle it
pass
else:
return config_path
# Move to parent directory
parent_dir = os.path.dirname(current_dir)
if parent_dir == current_dir:
# Reached root directory
break
current_dir = parent_dir
return None
def _build_ruff_config(linters: List[str], ignore: List[str], select: List[str]) -> Dict[str, Any]:
"""Build ruff configuration from pymode settings."""
config = {}
# Map old linter names to ruff rule categories
linter_mapping = {
'pyflakes': ['F'], # Pyflakes rules
'pycodestyle': ['E', 'W'], # pycodestyle rules
'pep8': ['E', 'W'], # Legacy pep8 (same as pycodestyle)
'mccabe': ['C90'], # McCabe complexity (C901 is specific, C90 is category)
'pylint': ['PL'], # Pylint rules
'pydocstyle': ['D'], # pydocstyle rules
'pep257': ['D'], # Legacy pep257 (same as pydocstyle)
'autopep8': ['E', 'W'], # Same as pycodestyle for checking
}
# Build select rules from linters and explicit select
select_rules = set()
# Add rules from explicit select first
if select:
select_rules.update(select)
# Add rules from enabled linters
for linter in linters:
if linter in linter_mapping:
select_rules.update(linter_mapping[linter])
# If no specific rules selected, use a sensible default
if not select_rules:
select_rules = {'F', 'E', 'W'} # Pyflakes + pycodestyle by default
config['select'] = list(select_rules)
# Add ignore rules
if ignore:
config['ignore'] = ignore
# Handle tool-specific options
_add_tool_specific_options(config, linters)
# Add other common settings
max_line_length = env.var('g:pymode_options_max_line_length', silence=True, default=79)
if max_line_length:
config['line-length'] = int(max_line_length)
return config
def _add_tool_specific_options(config: Dict[str, Any], linters: List[str]) -> None:
"""Add tool-specific configuration options."""
# Handle mccabe complexity
if 'mccabe' in linters:
mccabe_opts = env.var('g:pymode_lint_options_mccabe', silence=True, default={})
if mccabe_opts and 'complexity' in mccabe_opts:
# Ruff uses mccabe.max-complexity
config['mccabe'] = {'max-complexity': mccabe_opts['complexity']}
# Handle pycodestyle options
if 'pycodestyle' in linters or 'pep8' in linters:
pycodestyle_opts = env.var('g:pymode_lint_options_pycodestyle', silence=True, default={})
if pycodestyle_opts:
if 'max_line_length' in pycodestyle_opts:
config['line-length'] = pycodestyle_opts['max_line_length']
# Handle pylint options
if 'pylint' in linters:
pylint_opts = env.var('g:pymode_lint_options_pylint', silence=True, default={})
if pylint_opts:
if 'max-line-length' in pylint_opts:
config['line-length'] = pylint_opts['max-line-length']
# Handle pydocstyle/pep257 options
if 'pydocstyle' in linters or 'pep257' in linters:
pydocstyle_opts = env.var('g:pymode_lint_options_pep257', silence=True, default={})
# Most pydocstyle options don't have direct ruff equivalents
# Users should configure ruff directly for advanced docstring checking
# Handle pyflakes options
if 'pyflakes' in linters:
pyflakes_opts = env.var('g:pymode_lint_options_pyflakes', silence=True, default={})
# Pyflakes builtins option doesn't have a direct ruff equivalent
# Users can use ruff's built-in handling or per-file ignores
def _build_ruff_args(config: Dict[str, Any]) -> List[str]:
"""Build ruff command line arguments from configuration."""
args = []
# Add select rules
if 'select' in config:
# Join multiple rules with comma for efficiency
select_str = ','.join(config['select'])
args.extend(['--select', select_str])
# Add ignore rules
if 'ignore' in config:
# Join multiple rules with comma for efficiency
ignore_str = ','.join(config['ignore'])
args.extend(['--ignore', ignore_str])
# Add line length
if 'line-length' in config:
args.extend(['--line-length', str(config['line-length'])])
# Note: mccabe complexity needs to be set in pyproject.toml or ruff.toml
# We can't easily set it via command line args, so we'll document this limitation
return args
def validate_configuration() -> List[str]:
"""Validate pymode configuration for ruff compatibility.
Returns:
List of warning messages about configuration issues
"""
warnings = []
# Check if ruff is available
if not check_ruff_available():
warnings.append("Ruff is not installed. Please install with: pip install ruff")
return warnings
# Check linter configuration
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
supported_linters = {'pyflakes', 'pycodestyle', 'pep8', 'mccabe', 'pylint', 'pydocstyle', 'pep257'}
for linter in linters:
if linter not in supported_linters:
warnings.append(f"Linter '{linter}' is not supported by ruff integration")
# Check mccabe complexity configuration
if 'mccabe' in linters:
mccabe_opts = env.var('g:pymode_lint_options_mccabe', silence=True, default={})
if mccabe_opts and 'complexity' in mccabe_opts:
warnings.append("McCabe complexity setting requires ruff configuration file (pyproject.toml or ruff.toml)")
# Check for deprecated pep8 linter
if 'pep8' in linters:
warnings.append("'pep8' linter is deprecated, use 'pycodestyle' instead")
# Check for deprecated pep257 linter
if 'pep257' in linters:
warnings.append("'pep257' linter is deprecated, use 'pydocstyle' instead")
return warnings
def run_ruff_check(file_path: str, content: str = None) -> List[RuffError]:
"""Run ruff check on a file and return errors.
Args:
file_path: Path to the file to check
content: Optional file content (for checking unsaved buffers)
Returns:
List of RuffError objects
"""
# Check if Ruff is enabled
if not env.var('g:pymode_ruff_enabled', silence=True, default=True):
return []
try:
ruff_path = _get_ruff_executable()
except RuntimeError:
return []
# Get configuration mode
config_mode = env.var('g:pymode_ruff_config_mode', silence=True, default='local_override')
# Prepare command
cmd = [ruff_path, 'check', '--output-format=json']
# Check for local config file (used in multiple modes)
local_config = _find_local_ruff_config(file_path)
# Determine which config to use based on mode
if config_mode == 'local':
# Use only local config - don't pass any CLI config args
# If local config exists and we'll use a temp file, explicitly point to it
if local_config and content is not None:
cmd.extend(['--config', local_config])
# Otherwise, Ruff will auto-discover local config files
elif config_mode == 'local_override':
# Check if local config exists
if local_config:
# Local config found - use it
# If we'll use a temp file, explicitly point to the config
if content is not None:
cmd.extend(['--config', local_config])
# Otherwise, Ruff will auto-discover and use local config
else:
# No local config - use pymode settings as fallback
ruff_select = env.var('g:pymode_ruff_select', silence=True, default=[])
ruff_ignore = env.var('g:pymode_ruff_ignore', silence=True, default=[])
if ruff_select or ruff_ignore:
# Use Ruff-specific configuration
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = ruff_ignore if ruff_ignore else env.var('g:pymode_lint_ignore', default=[])
select = ruff_select if ruff_select else env.var('g:pymode_lint_select', default=[])
else:
# Use legacy configuration (backward compatibility)
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = env.var('g:pymode_lint_ignore', default=[])
select = env.var('g:pymode_lint_select', default=[])
# Build ruff configuration
config = _build_ruff_config(linters, ignore, select)
# Add configuration arguments
if config:
cmd.extend(_build_ruff_args(config))
elif config_mode == 'global':
# Use only pymode settings - ignore local configs
cmd.append('--isolated')
# Get pymode configuration
ruff_select = env.var('g:pymode_ruff_select', silence=True, default=[])
ruff_ignore = env.var('g:pymode_ruff_ignore', silence=True, default=[])
if ruff_select or ruff_ignore:
# Use Ruff-specific configuration
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = ruff_ignore if ruff_ignore else env.var('g:pymode_lint_ignore', default=[])
select = ruff_select if ruff_select else env.var('g:pymode_lint_select', default=[])
else:
# Use legacy configuration (backward compatibility)
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = env.var('g:pymode_lint_ignore', default=[])
select = env.var('g:pymode_lint_select', default=[])
# Build ruff configuration
config = _build_ruff_config(linters, ignore, select)
# Add configuration arguments
if config:
cmd.extend(_build_ruff_args(config))
else:
# Invalid mode - default to local_override behavior
env.debug(f"Invalid g:pymode_ruff_config_mode: {config_mode}, using 'local_override'")
if not local_config:
# No local config - use pymode settings
ruff_select = env.var('g:pymode_ruff_select', silence=True, default=[])
ruff_ignore = env.var('g:pymode_ruff_ignore', silence=True, default=[])
if ruff_select or ruff_ignore:
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = ruff_ignore if ruff_ignore else env.var('g:pymode_lint_ignore', default=[])
select = ruff_select if ruff_select else env.var('g:pymode_lint_select', default=[])
else:
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = env.var('g:pymode_lint_ignore', default=[])
select = env.var('g:pymode_lint_select', default=[])
config = _build_ruff_config(linters, ignore, select)
if config:
cmd.extend(_build_ruff_args(config))
# Handle content checking (for unsaved buffers)
temp_file_path = None
if content is not None:
# Write content to temporary file
fd, temp_file_path = tempfile.mkstemp(suffix='.py', prefix='pymode_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(content)
cmd.append(temp_file_path)
except Exception:
os.close(fd)
if temp_file_path:
os.unlink(temp_file_path)
raise
else:
cmd.append(file_path)
errors = []
try:
with silence_stderr():
# Run ruff
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
cwd=env.curdir
)
# Ruff returns non-zero exit code when issues are found
if result.stdout:
try:
# Parse JSON output
ruff_output = json.loads(result.stdout)
for item in ruff_output:
# Map temp file path back to original if needed
if temp_file_path and item.get('filename') == temp_file_path:
item['filename'] = file_path
errors.append(RuffError(item))
except json.JSONDecodeError as e:
env.debug(f"Failed to parse ruff JSON output: {e}")
env.debug(f"Raw output: {result.stdout}")
if result.stderr:
env.debug(f"Ruff stderr: {result.stderr}")
except subprocess.TimeoutExpired:
env.error("Ruff check timed out")
except Exception as e:
env.debug(f"Ruff check failed: {e}")
finally:
# Clean up temporary file
if temp_file_path:
try:
os.unlink(temp_file_path)
except OSError:
pass
return errors
def run_ruff_format(file_path: str, content: str = None) -> Optional[str]:
"""Run ruff format on a file and return formatted content.
Args:
file_path: Path to the file to format
content: Optional file content (for formatting unsaved buffers)
Returns:
Formatted content as string, or None if formatting failed
"""
try:
ruff_path = _get_ruff_executable()
except RuntimeError:
return None
# Check if formatting is enabled
if not env.var('g:pymode_ruff_format_enabled', silence=True, default=True):
return None
# Get configuration mode
config_mode = env.var('g:pymode_ruff_config_mode', silence=True, default='local_override')
# Check for local config file (used in multiple modes)
local_config = _find_local_ruff_config(file_path)
# Prepare command
cmd = [ruff_path, 'format', '--stdin-filename', file_path]
# Determine which config to use based on mode
if config_mode == 'local':
# Use only local config - Ruff will use --stdin-filename to discover config
# If local config exists, explicitly point to it for consistency
if local_config:
cmd.extend(['--config', local_config])
elif config_mode == 'local_override':
# Check if local config exists
if local_config:
# Local config found - explicitly use it
cmd.extend(['--config', local_config])
else:
# No local config - use pymode config file if specified
config_file = env.var('g:pymode_ruff_config_file', silence=True, default='')
if config_file and os.path.exists(config_file):
cmd.extend(['--config', config_file])
elif config_mode == 'global':
# Use only pymode settings - ignore local configs
cmd.append('--isolated')
# Use pymode config file if specified
config_file = env.var('g:pymode_ruff_config_file', silence=True, default='')
if config_file and os.path.exists(config_file):
cmd.extend(['--config', config_file])
else:
# Invalid mode - default to local_override behavior
env.debug(f"Invalid g:pymode_ruff_config_mode: {config_mode}, using 'local_override'")
if not local_config:
config_file = env.var('g:pymode_ruff_config_file', silence=True, default='')
if config_file and os.path.exists(config_file):
cmd.extend(['--config', config_file])
try:
with silence_stderr():
# Run ruff format
result = subprocess.run(
cmd,
input=content if content is not None else open(file_path).read(),
capture_output=True,
text=True,
timeout=30,
cwd=env.curdir
)
if result.returncode == 0:
return result.stdout
else:
# If ruff fails due to syntax errors, return original content
# This maintains backward compatibility with autopep8 behavior
# if "Failed to parse" in result.stderr or "SyntaxError" in result.stderr:
# env.debug(f"Ruff format skipped due to syntax errors: {result.stderr}")
# return content if content else None
env.debug(f"Ruff format failed: {result.stderr}")
return None
except subprocess.TimeoutExpired:
env.error("Ruff format timed out")
return None
except Exception as e:
env.debug(f"Ruff format failed: {e}")
return None
def check_ruff_available() -> bool:
"""Check if ruff is available and working."""
try:
_get_ruff_executable()
return True
except RuntimeError:
return False
# Legacy compatibility function
def code_check():
"""Run ruff check on current buffer (replaces pylama integration).
This function maintains compatibility with the existing pymode interface.
"""
if not env.curbuf.name:
return env.stop()
# Get file content from current buffer
content = '\n'.join(env.curbuf) + '\n'
file_path = env.curbuf.name
# Use relpath if possible, but handle Windows drive letter differences
try:
rel_path = os.path.relpath(file_path, env.curdir)
env.debug("Start ruff code check: ", rel_path)
except ValueError:
# On Windows, relpath fails if paths are on different drives
# Fall back to absolute path in this case
env.debug("Start ruff code check (abs path): ", file_path)
# Run ruff check
errors = run_ruff_check(file_path, content)
env.debug("Find errors: ", len(errors))
# Convert to vim-compatible format
errors_list = []
for error in errors:
err_dict = error.to_dict()
err_dict['bufnr'] = env.curbuf.number
errors_list.append(err_dict)
# Apply sorting if configured
sort_rules = env.var('g:pymode_lint_sort', default=[])
if sort_rules:
def __sort(e):
try:
return sort_rules.index(e.get('type'))
except ValueError:
return 999
errors_list = sorted(errors_list, key=__sort)
# Add to location list
env.run('g:PymodeLocList.current().extend', errors_list)