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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Shared utility for checking line length violations.
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"""
8from __future__ import annotations
10import json
11import os
12import shutil
13import subprocess # nosec B404 - used safely with shell disabled
14from dataclasses import dataclass
16from loguru import logger
19@dataclass
20class LineLengthViolation:
21 """Represents a line length violation.
23 This is a tool-agnostic data class that can be converted to any
24 tool-specific issue format (e.g., BlackIssue, RuffIssue).
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 """
34 file: str
35 line: int
36 column: int
37 message: str
38 code: str = "E501"
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.
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.
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.
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.
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 []
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 []
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 )
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 ]
107 if line_length is not None:
108 cmd.extend(["--line-length", str(line_length)])
110 cmd.extend(abs_files)
112 logger.debug(f"Running line length check: {' '.join(cmd)}")
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 )
124 # Parse JSON output
125 if not result.stdout.strip():
126 return []
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 []
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))
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)
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 )
156 return violations
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 []