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
« 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.
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"""
12import json
13import logging
14from dataclasses import dataclass
15from pathlib import Path
16from collections import defaultdict
17from tempfile import TemporaryDirectory
19from cikit.env import write_file
20from cikit.errors import CiToolsError
21from cikit.artifacts import Artifact, ArtifactType
22from cikit.rst_tools import RstBlock, RstDocument
24LOGGER = logging.getLogger(__name__)
27@dataclass
28class LintIssue:
29 """Unified representation of a linter issue.
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
45ICON_MAP = {
46 "error": "❗",
47 "warning": "⚠",
48 "refactor": "🟢",
49 "convention": "🟢",
50 "info": "ℹ",
51}
53DUPLICATE_CODE_ISSUES = {"duplicate-code", "r0801"}
54PROJECT_ISSUES = {*DUPLICATE_CODE_ISSUES, "cyclic-import", "r0401"}
57def get_severity(issue: LintIssue) -> int:
58 """Return numeric severity for sorting.
60 Args:
61 issue: LintIssue object
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)
70def is_project_issue(issue: LintIssue) -> bool:
71 """Return whether an issue belongs to the complete project.
73 Project issues are generated from information collected across multiple
74 source files and therefore must not be assigned to one individual file.
76 Args:
77 issue: Lint issue to inspect.
79 Returns:
80 True if the issue is project-wide.
81 """
82 return issue.code.lower() in PROJECT_ISSUES
85def read_source_lines(source_path: Path) -> list:
86 """Read source file lines for lint snippets.
88 Args:
89 source_path: Source file path.
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 []
102def trim_snippet(lines: list) -> tuple:
103 """Trim leading and trailing blank lines from a snippet.
105 Args:
106 lines: Snippet lines.
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
115 while first_idx <= last_idx and lines[first_idx].strip() == "":
116 first_idx += 1
118 while last_idx >= first_idx and lines[last_idx].strip() == "":
119 last_idx -= 1
121 if first_idx > last_idx:
122 return 0, lines
124 return first_idx, lines[first_idx:last_idx + 1]
127def extract_snippet(lines: list, line: int, context: int) -> tuple:
128 """Extract a raw source snippet around a lint issue.
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.
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)
143 return start_idx, issue_idx, lines[start_idx:end_idx]
146def source_snippet(source_path: Path, line: int, context: int = 5) -> tuple:
147 """Extract a source snippet around a lint issue.
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.
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>"]
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)))
166 return start_idx + 1, emphasize, snippet
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.
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.
177 Returns:
178 RST-formatted code-block lines.
179 """
180 lineno_start, emphasize, snippet = source_snippet(source_path, line, context)
182 rst = ["", ".. code-block:: python", " :force:", " :linenos:",
183 f" :lineno-start: {lineno_start}", f" :emphasize-lines: {emphasize}", ""]
185 for snippet_line in snippet:
186 rst.append(" " + snippet_line.rstrip("\n"))
188 return rst
191def issue_message_lines(issue: LintIssue) -> list:
192 """Render lint issue message lines.
194 Args:
195 issue: Lint issue to render.
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
205 return [f" {msg_line}" for msg_line in issue.message.splitlines()]
208def generate_issue_block(issue: LintIssue) -> list:
209 """Generate a compact RST block for a single issue.
211 Args:
212 issue: Lint issue object.
214 Returns:
215 RST lines representing the issue.
216 """
217 icon = ICON_MAP.get(issue.severity.lower(), "ℹ")
219 if is_project_issue(issue):
220 lines = [f"{icon} {issue.code}"]
221 else:
222 lines = [f"{icon} {issue.code} - Line {issue.line:<8}"]
224 lines.extend(issue_message_lines(issue))
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))
229 return lines
232def group_issues_by_file(issues: list) -> dict:
233 """Group lint issues by source file.
235 Args:
236 issues: Lint issues to group.
238 Returns:
239 Dictionary mapping source files to issue lists.
240 """
241 issues_by_file = defaultdict(list)
243 for issue in issues:
244 issues_by_file[issue.file_path].append(issue)
246 return dict(issues_by_file)
249def sort_issues(issues: list) -> list:
250 """Sort lint issues by severity and line.
252 Args:
253 issues: Lint issues to sort.
255 Returns:
256 Sorted lint issues.
257 """
258 return sorted(issues, key=lambda issue: (get_severity(issue), issue.line))
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.
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]
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)]
277 if project_issues:
278 project_block = RstBlock("Project issues")
280 for issue in sort_issues(project_issues):
281 project_block.add_block(RstBlock(lines=generate_issue_block(issue)))
283 document.add_block(project_block)
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
291 title = relative_path.as_posix().replace("_", r"\_")
292 file_block = RstBlock(title)
294 for issue in sort_issues(grouped_issues):
295 file_block.add_block(RstBlock(lines=generate_issue_block(issue)))
297 document.add_block(file_block)
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.
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.
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.
316 Returns:
317 Created and registered RST artifact.
318 """
319 filename = f"{basename}.rst"
321 add_issues_to_document(document, env["PROJECT_ROOT"], issues, filter_code=filter_code)
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)
329def read_lint_issues(json_file: Path, issue_factory) -> list:
330 """Read lint issues from a JSON report file.
332 Args:
333 json_file: Source JSON file written by a linter macro.
334 issue_factory: Callable converting one JSON entry into a LintIssue.
336 Returns:
337 Converted lint issues.
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
350 return [issue_factory(entry) for entry in data]