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

1"""Result formatting utilities for Lintro tool output. 

2 

3Handles formatting and display of individual tool results with rich colors and 

4status messages. 

5""" 

6 

7import json 

8import re 

9from collections.abc import Callable 

10 

11from loguru import logger 

12 

13from lintro.enums.action import Action, normalize_action 

14from lintro.enums.tool_name import ToolName 

15 

16# ANSI color codes for suppression table 

17_YELLOW = "\033[33m" 

18_RESET = "\033[0m" 

19_DIM = "\033[2m" 

20 

21 

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. 

27 

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 

34 

35 suppressions = ai_metadata.get("suppressions", []) 

36 if not isinstance(suppressions, list) or not suppressions: 

37 return 

38 

39 console_output_func(text="") 

40 console_output_func( 

41 text=" 🔇 Suppressed Vulnerabilities (via .osv-scanner.toml):", 

42 ) 

43 

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 

52 

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)}") 

61 

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", "")) 

69 

70 # Truncate long reasons 

71 max_reason = 60 

72 if len(reason) > max_reason: 

73 reason = reason[: max_reason - 1] + "…" 

74 

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} " 

81 

82 console_output_func( 

83 text=f" {sid:<{id_width}} {expires:<{exp_width}} " 

84 f"{status_display} {_DIM}{reason}{_RESET}", 

85 ) 

86 

87 console_output_func(text=f" {'-' * (len(header) - 2)}") 

88 

89 

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. 

102 

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() 

122 

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 

128 

129 # Extract coverage summary from JSON in output 

130 coverage_summary = None 

131 

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 

140 

141 for line in lines: 

142 stripped = line.strip() 

143 

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 

155 

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 

173 

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) 

234 

235 # Flush any remaining incomplete JSON to display_lines 

236 if json_buffer: 

237 display_lines.extend(json_buffer) 

238 

239 if display_lines: 

240 console_output_func(text="\n".join(display_lines)) 

241 

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) 

247 

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) 

253 

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 = "🔴" 

261 

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 ) 

274 

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.") 

278 

279 return 

280 

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") 

287 

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 

309 

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 

342 

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") 

377 

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)) 

402 

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 ) 

414 

415 # Display suppression table for osv-scanner when suppressions exist 

416 _print_suppression_table(console_output_func, ai_metadata) 

417 

418 console_output_func(text="") # Blank line after each tool