Coverage for lintro / tools / core / line_length_checker.py: 98%

58 statements  

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

1"""Shared utility for checking line length violations. 

2 

3This module provides a decoupled way to check for E501 (line too long) violations 

4using Ruff as the underlying checker. It avoids direct tool-to-tool imports, 

5making the architecture more modular. 

6""" 

7 

8from __future__ import annotations 

9 

10import json 

11import os 

12import shutil 

13import subprocess # nosec B404 - used safely with shell disabled 

14from dataclasses import dataclass 

15 

16from loguru import logger 

17 

18 

19@dataclass 

20class LineLengthViolation: 

21 """Represents a line length violation. 

22 

23 This is a tool-agnostic data class that can be converted to any 

24 tool-specific issue format (e.g., BlackIssue, RuffIssue). 

25 

26 Attributes: 

27 file: Absolute path to the file with the violation. 

28 line: Line number where the violation occurs. 

29 column: Column number (typically where the line exceeds the limit). 

30 message: Description of the violation from Ruff. 

31 code: The rule code (E501). 

32 """ 

33 

34 file: str 

35 line: int 

36 column: int 

37 message: str 

38 code: str = "E501" 

39 

40 

41def check_line_length_violations( 

42 files: list[str], 

43 cwd: str | None = None, 

44 line_length: int | None = None, 

45 timeout: int = 30, 

46) -> list[LineLengthViolation]: 

47 """Check files for line length violations using Ruff's E501 rule. 

48 

49 This function runs Ruff via subprocess to detect lines that exceed 

50 the configured line length limit. It's designed to be used by formatters 

51 like Black that cannot wrap certain long lines. 

52 

53 Args: 

54 files: List of file paths to check. Can be relative (to cwd) or absolute. 

55 cwd: Working directory for relative paths. If None, paths are treated 

56 as relative to the current directory. 

57 line_length: Maximum line length. If None, uses Ruff's default (88). 

58 timeout: Timeout in seconds for the Ruff subprocess. 

59 

60 Returns: 

61 List of LineLengthViolation objects representing E501 violations. 

62 Returns an empty list if Ruff is not available or if an error occurs. 

63 

64 Example: 

65 >>> violations = check_line_length_violations( 

66 ... files=["src/module.py"], 

67 ... cwd="/project", 

68 ... line_length=100, 

69 ... ) 

70 >>> for v in violations: 

71 ... print(f"{v.file}:{v.line} - {v.message}") 

72 """ 

73 if not files: 

74 return [] 

75 

76 # Check if Ruff is available 

77 ruff_path = shutil.which("ruff") 

78 if not ruff_path: 

79 logger.debug("Ruff not found in PATH, skipping line length check") 

80 return [] 

81 

82 # Convert relative paths to absolute paths 

83 abs_files: list[str] = [] 

84 for file_path in files: 

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

86 abs_files.append(os.path.abspath(os.path.join(cwd, file_path))) 

87 else: 

88 abs_files.append( 

89 ( 

90 os.path.abspath(file_path) 

91 if not os.path.isabs(file_path) 

92 else file_path 

93 ), 

94 ) 

95 

96 # Build the Ruff command 

97 cmd: list[str] = [ 

98 ruff_path, 

99 "check", 

100 "--select", 

101 "E501", 

102 "--output-format", 

103 "json", 

104 "--no-cache", # Avoid cache issues when checking specific files 

105 ] 

106 

107 if line_length is not None: 

108 cmd.extend(["--line-length", str(line_length)]) 

109 

110 cmd.extend(abs_files) 

111 

112 logger.debug(f"Running line length check: {' '.join(cmd)}") 

113 

114 try: 

115 result = subprocess.run( 

116 cmd, 

117 capture_output=True, 

118 text=True, 

119 timeout=timeout, 

120 cwd=cwd, 

121 check=False, # Don't raise on non-zero exit (violations cause exit 1) 

122 ) 

123 

124 # Parse JSON output 

125 if not result.stdout.strip(): 

126 return [] 

127 

128 try: 

129 issues_data = json.loads(result.stdout) 

130 except json.JSONDecodeError as e: 

131 logger.debug(f"Failed to parse Ruff JSON output: {e}") 

132 return [] 

133 

134 # Convert to LineLengthViolation objects 

135 violations: list[LineLengthViolation] = [] 

136 for issue in issues_data: 

137 # Ruff JSON format has: filename, row, column, message, code 

138 file_path = issue.get("filename", "") 

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

140 file_path = os.path.abspath(os.path.join(cwd, file_path)) 

141 

142 # Handle both old and new Ruff JSON formats 

143 line = issue.get("location", {}).get("row") or issue.get("row", 0) 

144 column = issue.get("location", {}).get("column") or issue.get("column", 0) 

145 

146 violations.append( 

147 LineLengthViolation( 

148 file=file_path, 

149 line=line, 

150 column=column, 

151 message=issue.get("message", "Line too long"), 

152 code=issue.get("code", "E501"), 

153 ), 

154 ) 

155 

156 return violations 

157 

158 except subprocess.TimeoutExpired: 

159 logger.debug(f"Line length check timed out after {timeout}s") 

160 return [] 

161 except FileNotFoundError: 

162 logger.debug("Ruff executable not found") 

163 return [] 

164 except (OSError, ValueError, RuntimeError) as e: 

165 logger.debug(f"Failed to check line length violations: {e}") 

166 return []