Coverage for lintro / utils / result_formatters.py: 57%
180 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"""Result formatting utilities for Lintro tool output.
3Handles formatting and display of individual tool results with rich colors and
4status messages.
5"""
7import json
8import re
9from collections.abc import Callable
11from loguru import logger
13from lintro.enums.action import Action, normalize_action
14from lintro.enums.tool_name import ToolName
16# ANSI color codes for suppression table
17_YELLOW = "\033[33m"
18_RESET = "\033[0m"
19_DIM = "\033[2m"
22def _print_suppression_table(
23 console_output_func: Callable[..., None],
24 ai_metadata: dict[str, object] | None,
25) -> None:
26 """Print suppression details table if ai_metadata contains suppressions.
28 Args:
29 console_output_func: Function to output text to console.
30 ai_metadata: Tool metadata dict with optional suppressions list.
31 """
32 if not isinstance(ai_metadata, dict):
33 return
35 suppressions = ai_metadata.get("suppressions", [])
36 if not isinstance(suppressions, list) or not suppressions:
37 return
39 console_output_func(text="")
40 console_output_func(
41 text=" 🔇 Suppressed Vulnerabilities (via .osv-scanner.toml):",
42 )
44 # Compute column widths
45 id_width = max(
46 (len(str(s.get("id", ""))) for s in suppressions if isinstance(s, dict)),
47 default=0,
48 )
49 id_width = max(id_width, 2) + 2 # padding
50 exp_width = 12 # YYYY-MM-DD + padding
51 status_width = 11 # "⚠ Expired" + padding
53 # Header
54 header = (
55 f" {'ID':<{id_width}} {'Expires':<{exp_width}} "
56 f"{'Status':<{status_width}} Reason"
57 )
58 console_output_func(text=f" {'-' * (len(header) - 2)}")
59 console_output_func(text=header)
60 console_output_func(text=f" {'-' * (len(header) - 2)}")
62 for s in suppressions:
63 if not isinstance(s, dict):
64 continue
65 sid = str(s.get("id", "?"))
66 expires = str(s.get("ignore_until", "?"))
67 status = str(s.get("status", "active"))
68 reason = str(s.get("reason", ""))
70 # Truncate long reasons
71 max_reason = 60
72 if len(reason) > max_reason:
73 reason = reason[: max_reason - 1] + "…"
75 if status == "expired":
76 status_display = f"{_YELLOW}⚠ Expired{_RESET}"
77 elif status == "stale":
78 status_display = f"{_YELLOW}⚠ Stale{_RESET}"
79 else:
80 status_display = f"{_DIM}Active{_RESET} "
82 console_output_func(
83 text=f" {sid:<{id_width}} {expires:<{exp_width}} "
84 f"{status_display} {_DIM}{reason}{_RESET}",
85 )
87 console_output_func(text=f" {'-' * (len(header) - 2)}")
90def print_tool_result(
91 console_output_func: Callable[..., None],
92 success_func: Callable[..., None],
93 tool_name: str,
94 output: str,
95 issues_count: int,
96 raw_output_for_meta: str | None = None,
97 action: str | Action = "check",
98 success: bool | None = None,
99 ai_metadata: dict[str, object] | None = None,
100) -> None:
101 """Print the result for a tool.
103 Args:
104 console_output_func: Function to output text to console
105 success_func: Function to display success message
106 tool_name: str: The name of the tool.
107 output: str: The output from the tool.
108 issues_count: int: The number of issues found.
109 raw_output_for_meta: str | None: Raw tool output used to extract
110 fixable/remaining hints when available.
111 action: str | Action: The action being performed ("check", "fmt", "test").
112 success: bool | None: Whether the tool run succeeded. When False,
113 the result is treated as a failure even if no issues were
114 counted (e.g., parse or runtime errors).
115 ai_metadata: dict[str, object] | None: Tool-specific metadata
116 (e.g. suppression classifications for osv-scanner).
117 """
118 # Normalize action to enum
119 action = normalize_action(action)
120 # Normalize tool name for consistent comparisons
121 tool_name_normalized = tool_name.lower()
123 # Add section header for pytest/test results
124 if tool_name_normalized == ToolName.PYTEST.value:
125 console_output_func(text="")
126 console_output_func(text="🧪 Test Results")
127 console_output_func(text="-" * 20) # Simplified border length
129 # Extract coverage summary from JSON in output
130 coverage_summary = None
132 # Display formatted test failures table if present
133 # Skip JSON lines and verbose coverage output, extract coverage data from JSON
134 if output and output.strip():
135 lines = output.split("\n")
136 display_lines = []
137 json_buffer: list[str] = []
138 in_json = False
139 in_coverage_section = False
141 for line in lines:
142 stripped = line.strip()
144 # Skip verbose coverage table lines
145 # Detect coverage section start
146 if (
147 "coverage:" in stripped.lower() and "platform" in stripped.lower()
148 ) or (
149 stripped.startswith("Name")
150 and "Stmts" in stripped
151 and "Miss" in stripped
152 ):
153 in_coverage_section = True
154 continue
156 # Skip lines within coverage section
157 if in_coverage_section:
158 # Coverage section ends at empty line or new section marker
159 if stripped == "" or stripped.startswith("==="):
160 in_coverage_section = False
161 # Don't add the empty line/marker that ends coverage
162 if stripped.startswith("==="):
163 display_lines.append(line)
164 continue
165 # Skip coverage data lines (files, TOTAL, dashes, etc.)
166 if (
167 stripped.startswith("-")
168 or stripped.startswith("TOTAL")
169 or "%" in stripped
170 or stripped.startswith("Coverage")
171 ):
172 continue
174 # More specific JSON detection: only start JSON collection for:
175 # - Lines starting with '{' (JSON object)
176 # - Lines starting with '[' followed by JSON-like content
177 # (not pytest progress like [100%])
178 is_json_start = stripped.startswith("{") or (
179 stripped.startswith("[")
180 and len(stripped) > 1
181 and stripped[1]
182 in (
183 "{",
184 '"',
185 "'",
186 "[",
187 "-",
188 "0",
189 "1",
190 "2",
191 "3",
192 "4",
193 "5",
194 "6",
195 "7",
196 "8",
197 "9",
198 "t",
199 "f",
200 "n",
201 )
202 )
203 if is_json_start:
204 # Try to parse single-line JSON first
205 try:
206 parsed_json = json.loads(stripped)
207 # Extract coverage summary if present
208 if isinstance(parsed_json, dict) and "coverage" in parsed_json:
209 coverage_summary = parsed_json["coverage"]
210 # Successfully parsed single-line JSON - skip it
211 continue
212 except json.JSONDecodeError:
213 # Not complete, start collecting multi-line JSON
214 json_buffer = [line]
215 in_json = True
216 continue
217 if in_json:
218 json_buffer.append(line)
219 # Try to parse accumulated JSON
220 try:
221 json_str = "\n".join(json_buffer)
222 parsed_json = json.loads(json_str)
223 # Extract coverage summary if present
224 if isinstance(parsed_json, dict) and "coverage" in parsed_json:
225 coverage_summary = parsed_json["coverage"]
226 # Successfully parsed - skip this JSON block
227 json_buffer = []
228 in_json = False
229 except json.JSONDecodeError:
230 # Not complete yet, continue collecting
231 continue
232 # Keep everything else including table headers and content
233 display_lines.append(line)
235 # Flush any remaining incomplete JSON to display_lines
236 if json_buffer:
237 display_lines.extend(json_buffer)
239 if display_lines:
240 console_output_func(text="\n".join(display_lines))
242 # Display coverage summary as a clean table if present
243 if coverage_summary:
244 console_output_func(text="")
245 console_output_func(text="📊 Coverage Summary")
246 console_output_func(text="-" * 20)
248 coverage_pct = coverage_summary.get("coverage_pct", 0)
249 total_stmts = coverage_summary.get("total_stmts", 0)
250 covered_stmts = coverage_summary.get("covered_stmts", 0)
251 missing_stmts = coverage_summary.get("missing_stmts", 0)
252 files_count = coverage_summary.get("files_count", 0)
254 # Choose color/emoji based on coverage percentage
255 if coverage_pct >= 80:
256 cov_indicator = "🟢"
257 elif coverage_pct >= 60:
258 cov_indicator = "🟡"
259 else:
260 cov_indicator = "🔴"
262 console_output_func(
263 text=f"{cov_indicator} Coverage: {coverage_pct}%",
264 )
265 console_output_func(
266 text=f" Lines: {covered_stmts:,} / {total_stmts:,} covered",
267 )
268 console_output_func(
269 text=f" Missing: {missing_stmts:,} lines",
270 )
271 console_output_func(
272 text=f" Files: {files_count:,}",
273 )
275 # Don't show summary line here - it will be in the Execution Summary table
276 if issues_count == 0 and not output:
277 success_func(message="✓ No issues found.")
279 return
281 if output and output.strip():
282 # Display the output (either raw or formatted, depending on what was passed)
283 console_output_func(text=output)
284 logger.debug(f"Tool {tool_name} output: {len(output)} characters")
285 else:
286 logger.debug(f"Tool {tool_name} produced no output")
288 # Print result status
289 if issues_count == 0:
290 # For format action, prefer consolidated fixed summary if present
291 if action == Action.FIX and output and output.strip():
292 # If output contains a consolidated fixed count, surface it
293 m_fixed = re.search(r"Fixed (\d+) issue\(s\)", output)
294 m_remaining = re.search(
295 r"Found (\d+) issue\(s\) that cannot be auto-fixed",
296 output,
297 )
298 fixed_val = int(m_fixed.group(1)) if m_fixed else 0
299 remaining_val = int(m_remaining.group(1)) if m_remaining else 0
300 if fixed_val > 0 or remaining_val > 0:
301 if fixed_val > 0:
302 console_output_func(text=f"✓ {fixed_val} fixed", color="green")
303 if remaining_val > 0:
304 console_output_func(
305 text=f"✗ {remaining_val} remaining",
306 color="red",
307 )
308 return
310 # If the tool reported a failure (e.g., parse error), do not claim pass
311 if success is False:
312 console_output_func(text="✗ Tool execution failed", color="red")
313 # Check if the output indicates no files were processed
314 elif output and any(
315 (msg in output for msg in ["No files to", "No Python files found to"]),
316 ):
317 console_output_func(
318 text=("⚠️ No files processed (excluded by patterns)"),
319 )
320 else:
321 # For format operations, check if there are remaining issues that
322 # couldn't be auto-fixed
323 if output and "cannot be auto-fixed" in output.lower():
324 # Don't show "No issues found" if there are remaining issues
325 pass
326 else:
327 success_func(message="✓ No issues found.")
328 else:
329 # For format operations, parse the output to show better messages
330 if output and ("Fixed" in output or "issue(s)" in output):
331 # This is a format operation - parse for better messaging
332 # Parse counts from output string
333 fixed_match = re.search(r"Fixed (\d+) issue\(s\)", output)
334 fixed_count = int(fixed_match.group(1)) if fixed_match else 0
335 remaining_match = re.search(
336 r"Found (\d+) issue\(s\) that cannot be auto-fixed",
337 output,
338 )
339 remaining_count = int(remaining_match.group(1)) if remaining_match else 0
340 initial_match = re.search(r"Found (\d+) errors?", output)
341 initial_count = int(initial_match.group(1)) if initial_match else 0
343 if fixed_count > 0 and remaining_count == 0:
344 success_func(message=f"✓ {fixed_count} fixed")
345 elif fixed_count > 0 and remaining_count > 0:
346 console_output_func(
347 text=f"✓ {fixed_count} fixed",
348 color="green",
349 )
350 console_output_func(
351 text=f"✗ {remaining_count} remaining",
352 color="red",
353 )
354 elif remaining_count > 0:
355 console_output_func(
356 text=f"✗ {remaining_count} remaining",
357 color="red",
358 )
359 elif initial_count > 0:
360 # If we found initial issues but no specific fixed/remaining counts,
361 # show the initial count as found
362 console_output_func(
363 text=f"✗ Found {initial_count} issues",
364 color="red",
365 )
366 else:
367 # Fallback to original behavior
368 error_msg = f"✗ Found {issues_count} issues"
369 console_output_func(text=error_msg, color="red")
370 else:
371 # Show issue count with action-aware phrasing
372 if action == Action.FIX:
373 error_msg = f"✗ {issues_count} issue(s) cannot be auto-fixed"
374 else:
375 error_msg = f"✗ Found {issues_count} issues"
376 console_output_func(text=error_msg, color="red")
378 # Check if there are fixable issues and show warning
379 raw_text = (
380 raw_output_for_meta if raw_output_for_meta is not None else output
381 )
382 # Sum all fixable counts if multiple sections are present
383 if raw_text and action != Action.FIX:
384 # Sum any reported fixable lint issues
385 matches = re.findall(r"\[\*\]\s+(\d+)\s+fixable", raw_text)
386 fixable_count: int = sum(int(m) for m in matches) if matches else 0
387 # Add formatting issues as fixable by fmt when ruff reports them
388 if tool_name_normalized == ToolName.RUFF.value and (
389 "Formatting issues:" in raw_text or "Would reformat" in raw_text
390 ):
391 # Count files listed in 'Would reformat:' lines
392 reformat_files = re.findall(r"Would reformat:\s+(.+)", raw_text)
393 fixable_count += len(reformat_files)
394 # Or try summary line like: "N files would be reformatted"
395 if fixable_count == 0:
396 m_sum = re.search(
397 r"(\d+)\s+file(?:s)?\s+would\s+be\s+reformatted",
398 raw_text,
399 )
400 if m_sum:
401 fixable_count += int(m_sum.group(1))
403 if fixable_count > 0:
404 hint_a: str = "💡 "
405 hint_b: str = (
406 f"{fixable_count} formatting/linting issue(s) "
407 "can be auto-fixed "
408 )
409 hint_c: str = "with `lintro format`"
410 console_output_func(
411 text=hint_a + hint_b + hint_c,
412 color="yellow",
413 )
415 # Display suppression table for osv-scanner when suppressions exist
416 _print_suppression_table(console_output_func, ai_metadata)
418 console_output_func(text="") # Blank line after each tool