Coverage for /builds/svesch/cikit/src/cikit/lint_to_rst.py: 99%

121 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-09 14:17 +0000

1""" 

2Core logic to generate reStructuredText (RST) from a unified list of linter issues. 

3 

4This module provides: 

5- LintIssue data class as unified representation 

6- Functions to generate compact RST reports 

7- Filtering by rule code 

8- Top two path headings + file/remaining path subheading 

9- Duplicate-code handling as code block 

10""" 

11 

12import json 

13import logging 

14from dataclasses import dataclass 

15from pathlib import Path 

16from collections import defaultdict 

17from tempfile import TemporaryDirectory 

18 

19from cikit.env import write_file 

20from cikit.errors import CiToolsError 

21from cikit.artifacts import Artifact, ArtifactType 

22from cikit.rst_tools import RstBlock, RstDocument 

23 

24LOGGER = logging.getLogger(__name__) 

25 

26 

27@dataclass 

28class LintIssue: 

29 """Unified representation of a linter issue. 

30 

31 Args: 

32 code: Full rule code (e.g., missing-module-docstring, E501) 

33 file_path: Path to the source file 

34 line: Line number of the issue 

35 message: Full message string 

36 severity: Severity string ("error", "warning", "refactor", "info") 

37 """ 

38 code: str 

39 file_path: Path 

40 line: int 

41 message: str 

42 severity: str 

43 

44 

45ICON_MAP = { 

46 "error": "❗", 

47 "warning": "⚠", 

48 "refactor": "🟢", 

49 "convention": "🟢", 

50 "info": "ℹ", 

51} 

52 

53DUPLICATE_CODE_ISSUES = {"duplicate-code", "r0801"} 

54PROJECT_ISSUES = {*DUPLICATE_CODE_ISSUES, "cyclic-import", "r0401"} 

55 

56 

57def get_severity(issue: LintIssue) -> int: 

58 """Return numeric severity for sorting. 

59 

60 Args: 

61 issue: LintIssue object 

62 

63 Returns: 

64 Integer severity (lower = more severe) 

65 """ 

66 severity_order = {"error": 0, "warning": 1, "refactor": 2, "convention": 2, "info": 3} 

67 return severity_order.get(issue.severity.lower(), 99) 

68 

69 

70def is_project_issue(issue: LintIssue) -> bool: 

71 """Return whether an issue belongs to the complete project. 

72 

73 Project issues are generated from information collected across multiple 

74 source files and therefore must not be assigned to one individual file. 

75 

76 Args: 

77 issue: Lint issue to inspect. 

78 

79 Returns: 

80 True if the issue is project-wide. 

81 """ 

82 return issue.code.lower() in PROJECT_ISSUES 

83 

84 

85def read_source_lines(source_path: Path) -> list: 

86 """Read source file lines for lint snippets. 

87 

88 Args: 

89 source_path: Source file path. 

90 

91 Returns: 

92 Source file lines or an empty list if reading fails. 

93 """ 

94 try: 

95 with source_path.open(encoding="utf-8") as fil: 

96 return fil.readlines() 

97 except OSError as exp: 

98 LOGGER.warning("Got error while parsing source file '%s': %s", source_path, exp) 

99 return [] 

100 

101 

102def trim_snippet(lines: list) -> tuple: 

103 """Trim leading and trailing blank lines from a snippet. 

104 

105 Args: 

106 lines: Snippet lines. 

107 

108 Returns: 

109 Tuple containing the number of removed leading lines and the 

110 trimmed snippet. 

111 """ 

112 first_idx = 0 

113 last_idx = len(lines) - 1 

114 

115 while first_idx <= last_idx and lines[first_idx].strip() == "": 

116 first_idx += 1 

117 

118 while last_idx >= first_idx and lines[last_idx].strip() == "": 

119 last_idx -= 1 

120 

121 if first_idx > last_idx: 

122 return 0, lines 

123 

124 return first_idx, lines[first_idx:last_idx + 1] 

125 

126 

127def extract_snippet(lines: list, line: int, context: int) -> tuple: 

128 """Extract a raw source snippet around a lint issue. 

129 

130 Args: 

131 lines: Full source file lines. 

132 line: One-based lint issue line. 

133 context: Number of context lines before and after the issue. 

134 

135 Returns: 

136 Tuple with zero-based snippet start index, zero-based issue index 

137 and extracted snippet lines. 

138 """ 

139 issue_idx = max(0, min(line - 1, len(lines) - 1)) 

140 start_idx = max(0, issue_idx - context) 

141 end_idx = min(len(lines), issue_idx + context + 1) 

142 

143 return start_idx, issue_idx, lines[start_idx:end_idx] 

144 

145 

146def source_snippet(source_path: Path, line: int, context: int = 5) -> tuple: 

147 """Extract a source snippet around a lint issue. 

148 

149 Args: 

150 source_path: Source file path. 

151 line: One-based lint issue line. 

152 context: Number of context lines before and after the issue. 

153 

154 Returns: 

155 Tuple with line number start, emphasized line and snippet lines. 

156 """ 

157 lines = read_source_lines(source_path) 

158 if not lines: 

159 return 1, 1, ["# <empty file>"] 

160 

161 start_idx, issue_idx, snippet = extract_snippet(lines, line, context) 

162 offset, snippet = trim_snippet(snippet) 

163 start_idx += offset 

164 emphasize = max(1, min((issue_idx - start_idx) + 1, len(snippet))) 

165 

166 return start_idx + 1, emphasize, snippet 

167 

168 

169def make_lint_rst_snippet(source_path: Path, line: int, context: int = 5) -> list: 

170 """Generate an RST code-block snippet around a lint issue. 

171 

172 Args: 

173 source_path: Path to the Python source file. 

174 line: One-based line number where the lint issue occurred. 

175 context: Number of lines before and after the issue. 

176 

177 Returns: 

178 RST-formatted code-block lines. 

179 """ 

180 lineno_start, emphasize, snippet = source_snippet(source_path, line, context) 

181 

182 rst = ["", ".. code-block:: python", " :force:", " :linenos:", 

183 f" :lineno-start: {lineno_start}", f" :emphasize-lines: {emphasize}", ""] 

184 

185 for snippet_line in snippet: 

186 rst.append(" " + snippet_line.rstrip("\n")) 

187 

188 return rst 

189 

190 

191def issue_message_lines(issue: LintIssue) -> list: 

192 """Render lint issue message lines. 

193 

194 Args: 

195 issue: Lint issue to render. 

196 

197 Returns: 

198 RST lines for the issue message. 

199 """ 

200 if issue.code.lower() in DUPLICATE_CODE_ISSUES: 

201 lines = [" .. code-block:: text", ""] 

202 lines.extend(f" {msg_line}" for msg_line in issue.message.splitlines()) 

203 return lines 

204 

205 return [f" {msg_line}" for msg_line in issue.message.splitlines()] 

206 

207 

208def generate_issue_block(issue: LintIssue) -> list: 

209 """Generate a compact RST block for a single issue. 

210 

211 Args: 

212 issue: Lint issue object. 

213 

214 Returns: 

215 RST lines representing the issue. 

216 """ 

217 icon = ICON_MAP.get(issue.severity.lower(), "ℹ") 

218 

219 if is_project_issue(issue): 

220 lines = [f"{icon} {issue.code}"] 

221 else: 

222 lines = [f"{icon} {issue.code} - Line {issue.line:<8}"] 

223 

224 lines.extend(issue_message_lines(issue)) 

225 

226 if issue.file_path.suffix == ".py" and not is_project_issue(issue): 

227 lines.extend(make_lint_rst_snippet(issue.file_path, issue.line)) 

228 

229 return lines 

230 

231 

232def group_issues_by_file(issues: list) -> dict: 

233 """Group lint issues by source file. 

234 

235 Args: 

236 issues: Lint issues to group. 

237 

238 Returns: 

239 Dictionary mapping source files to issue lists. 

240 """ 

241 issues_by_file = defaultdict(list) 

242 

243 for issue in issues: 

244 issues_by_file[issue.file_path].append(issue) 

245 

246 return dict(issues_by_file) 

247 

248 

249def sort_issues(issues: list) -> list: 

250 """Sort lint issues by severity and line. 

251 

252 Args: 

253 issues: Lint issues to sort. 

254 

255 Returns: 

256 Sorted lint issues. 

257 """ 

258 return sorted(issues, key=lambda issue: (get_severity(issue), issue.line)) 

259 

260 

261def add_issues_to_document(document: RstDocument, project_path: Path, issues: list, *, 

262 filter_code: str = None): 

263 """Add lint issues to an existing RST document. 

264 

265 Args: 

266 document: Target document. 

267 project_path: Absolute project path used for relative display paths. 

268 issues: Lint issues to add. 

269 filter_code: Optional lint rule code filter. 

270 """ 

271 if filter_code: 

272 issues = [issue for issue in issues if issue.code == filter_code] 

273 

274 project_issues = [issue for issue in issues if is_project_issue(issue)] 

275 file_issues = [issue for issue in issues if not is_project_issue(issue)] 

276 

277 if project_issues: 

278 project_block = RstBlock("Project issues") 

279 

280 for issue in sort_issues(project_issues): 

281 project_block.add_block(RstBlock(lines=generate_issue_block(issue))) 

282 

283 document.add_block(project_block) 

284 

285 for file_path, grouped_issues in sorted(group_issues_by_file(file_issues).items()): 

286 try: 

287 relative_path = file_path.relative_to(project_path) 

288 except ValueError: 

289 relative_path = file_path 

290 

291 title = relative_path.as_posix().replace("_", r"\_") 

292 file_block = RstBlock(title) 

293 

294 for issue in sort_issues(grouped_issues): 

295 file_block.add_block(RstBlock(lines=generate_issue_block(issue))) 

296 

297 document.add_block(file_block) 

298 

299 

300def write_lint_rst(env, document: RstDocument, issues: list, basename: str, 

301 filter_code: str = None) -> Artifact: 

302 """Create and register a lint RST report. 

303 

304 The supplied document may already contain additional sections such as 

305 timeline charts or badges. Lint issues are appended to the document, 

306 the report is created in a temporary directory and then copied to the 

307 public RST artifact directory. 

308 

309 Args: 

310 env: Resolved CI run environment. 

311 document: Target RST document. 

312 issues: Lint issues to include in the report. 

313 basename: Output file basename without extension. 

314 filter_code: Optional lint rule code filter. 

315 

316 Returns: 

317 Created and registered RST artifact. 

318 """ 

319 filename = f"{basename}.rst" 

320 

321 add_issues_to_document(document, env["PROJECT_ROOT"], issues, filter_code=filter_code) 

322 

323 with TemporaryDirectory() as temp_dir: 

324 source_file = Path(temp_dir) / filename 

325 write_file(source_file, document.text()) 

326 return env.copy_artifact(source_file, ArtifactType.RST) 

327 

328 

329def read_lint_issues(json_file: Path, issue_factory) -> list: 

330 """Read lint issues from a JSON report file. 

331 

332 Args: 

333 json_file: Source JSON file written by a linter macro. 

334 issue_factory: Callable converting one JSON entry into a LintIssue. 

335 

336 Returns: 

337 Converted lint issues. 

338 

339 Raises: 

340 CiToolsError: If the JSON file cannot be read or parsed. 

341 """ 

342 try: 

343 with json_file.open(encoding="utf-8") as fil: 

344 data = json.load(fil) 

345 except OSError as exp: 

346 raise CiToolsError(f"Error while reading lint JSON file '{json_file}': {exp}") from exp 

347 except json.JSONDecodeError as exp: 

348 raise CiToolsError(f"Error while parsing lint JSON file '{json_file}': {exp}") from exp 

349 

350 return [issue_factory(entry) for entry in data]