Coverage for src / tracekit / ui / formatters.py: 99%

169 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""UI-specific formatting utilities for TraceKit displays. 

2 

3This module provides formatting functions tailored for user interface display, 

4including text alignment, truncation, color codes, and structured output. 

5 

6- UI formatting for terminal and web outputs 

7 

8Example: 

9 >>> from tracekit.ui.formatters import format_text, truncate, colorize 

10 >>> format_text("Status", "active", align="left", width=20) 

11 ' Status: active' 

12 >>> truncate("Very long text here", max_length=10) 

13 'Very lon...' 

14 >>> colorize("Success", color="green") 

15 '\x1b[32mSuccess\x1b[0m' 

16""" 

17 

18from __future__ import annotations 

19 

20from dataclasses import dataclass 

21from enum import Enum 

22from typing import Any, Literal 

23 

24# Type alias for color names accepted by colorize() 

25type ColorName = Literal["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] 

26 

27 

28class Color(Enum): 

29 """ANSI color codes for terminal output.""" 

30 

31 BLACK = "\033[30m" 

32 RED = "\033[31m" 

33 GREEN = "\033[32m" 

34 YELLOW = "\033[33m" 

35 BLUE = "\033[34m" 

36 MAGENTA = "\033[35m" 

37 CYAN = "\033[36m" 

38 WHITE = "\033[37m" 

39 RESET = "\033[0m" 

40 

41 

42class TextAlignment(Enum): 

43 """Text alignment options.""" 

44 

45 LEFT = "left" 

46 CENTER = "center" 

47 RIGHT = "right" 

48 

49 

50@dataclass 

51class FormattedText: 

52 """Container for formatted text output. 

53 

54 Attributes: 

55 content: The formatted text content. 

56 color: Optional color code. 

57 bold: Whether text should be bold. 

58 width: Display width of the text. 

59 """ 

60 

61 content: str 

62 color: Color | None = None 

63 bold: bool = False 

64 width: int = 0 

65 

66 def __str__(self) -> str: 

67 """Get string representation with ANSI codes.""" 

68 result = self.content 

69 if self.bold: 

70 result = f"\033[1m{result}\033[0m" 

71 if self.color and self.color != Color.RESET: 

72 result = f"{self.color.value}{result}{Color.RESET.value}" 

73 return result 

74 

75 

76def colorize( 

77 text: str, 

78 color: ColorName = "white", 

79 bold: bool = False, 

80) -> str: 

81 """Apply ANSI color codes to text. 

82 

83 Args: 

84 text: Text to colorize. 

85 color: Color name (black, red, green, yellow, blue, magenta, cyan, white). 

86 bold: Apply bold formatting. 

87 

88 Returns: 

89 Text with ANSI color codes. 

90 

91 Example: 

92 >>> colorize("Error", color="red") 

93 '\x1b[31mError\x1b[0m' 

94 """ 

95 try: 

96 color_enum = Color[color.upper()] 

97 except KeyError: 

98 color_enum = Color.WHITE 

99 

100 result = text 

101 if bold: 

102 result = f"\033[1m{result}\033[0m" 

103 result = f"{color_enum.value}{result}{Color.RESET.value}" 

104 return result 

105 

106 

107def truncate( 

108 text: str, 

109 max_length: int, 

110 suffix: str = "...", 

111) -> str: 

112 """Truncate text to maximum length with suffix. 

113 

114 Args: 

115 text: Text to truncate. 

116 max_length: Maximum length including suffix. 

117 suffix: Suffix to append if truncated (default "..."). 

118 

119 Returns: 

120 Truncated text. 

121 

122 Example: 

123 >>> truncate("Very long text here", max_length=10) 

124 'Very lon...' 

125 """ 

126 if len(text) <= max_length: 

127 return text 

128 

129 # Account for suffix length 

130 truncate_at = max(0, max_length - len(suffix)) 

131 return text[:truncate_at] + suffix 

132 

133 

134def align_text( 

135 text: str, 

136 width: int, 

137 alignment: Literal["left", "center", "right"] = "left", 

138 fill_char: str = " ", 

139) -> str: 

140 """Align text within a given width. 

141 

142 Args: 

143 text: Text to align. 

144 width: Target width. 

145 alignment: How to align (left, center, right). 

146 fill_char: Character to use for padding. 

147 

148 Returns: 

149 Aligned text. 

150 

151 Example: 

152 >>> align_text("Hello", 10, "center") 

153 ' Hello ' 

154 """ 

155 if len(text) >= width: 

156 return text 

157 

158 if alignment == "center": 

159 return text.center(width, fill_char) 

160 elif alignment == "right": 

161 return text.rjust(width, fill_char) 

162 else: # left 

163 return text.ljust(width, fill_char) 

164 

165 

166def format_text( 

167 label: str, 

168 value: Any, 

169 align: Literal["left", "center", "right"] = "left", 

170 width: int | None = None, 

171 separator: str = ": ", 

172 color: ColorName | None = None, 

173) -> str: 

174 """Format a label-value pair for display. 

175 

176 Args: 

177 label: Label/key. 

178 value: Value to display. 

179 align: Text alignment. 

180 width: Total width for alignment (including label, separator, value). 

181 separator: Separator between label and value. 

182 color: Optional color for the value. 

183 

184 Returns: 

185 Formatted text. 

186 

187 Example: 

188 >>> format_text("Status", "active", align="left", width=20) 

189 ' Status: active' 

190 """ 

191 result = f"{label}{separator}{value}" 

192 

193 if color: 

194 result = f"{label}{separator}{colorize(str(value), color=color)}" 

195 

196 if width: 

197 result = align_text(result, width, alignment=align) 

198 

199 return result 

200 

201 

202def format_table( 

203 data: list[list[Any]], 

204 headers: list[str] | None = None, 

205 column_widths: list[int] | None = None, 

206 align_columns: list[Literal["left", "center", "right"]] | None = None, 

207) -> str: 

208 """Format data as a simple table. 

209 

210 Args: 

211 data: List of rows (each row is a list of values). 

212 headers: Optional header row. 

213 column_widths: Optional column widths (auto-calculated if not provided). 

214 align_columns: Alignment for each column. 

215 

216 Returns: 

217 Formatted table as string. 

218 

219 Example: 

220 >>> data = [["Alice", 85], ["Bob", 92]] 

221 >>> format_table(data, headers=["Name", "Score"]) 

222 """ 

223 if not data: 

224 return "" 

225 

226 # Calculate column widths 

227 num_cols = len(data[0]) if data else 0 

228 if headers: 

229 num_cols = len(headers) 

230 

231 if column_widths is None: 

232 column_widths = [] 

233 for col_idx in range(num_cols): 

234 max_width = 0 

235 if headers and col_idx < len(headers): 

236 max_width = len(str(headers[col_idx])) 

237 for row in data: 

238 if col_idx < len(row): 

239 max_width = max(max_width, len(str(row[col_idx]))) 

240 column_widths.append(max_width + 2) 

241 

242 # Default alignment 

243 if align_columns is None: 

244 align_columns = ["left"] * num_cols 

245 

246 output_lines = [] 

247 

248 # Add headers if provided 

249 if headers: 

250 header_cells = [] 

251 for col_idx, header in enumerate(headers): 

252 width = column_widths[col_idx] if col_idx < len(column_widths) else 10 

253 aligned = align_text(str(header), width, align_columns[col_idx]) 

254 header_cells.append(aligned) 

255 output_lines.append(" ".join(header_cells)) 

256 # Add separator 

257 separator = "-" * sum(column_widths) + ("-" * (num_cols - 1)) 

258 output_lines.append(separator) 

259 

260 # Add data rows 

261 for row in data: 

262 row_cells = [] 

263 for col_idx, cell in enumerate(row): 

264 width = column_widths[col_idx] if col_idx < len(column_widths) else 10 

265 aligned = align_text(str(cell), width, align_columns[col_idx]) 

266 row_cells.append(aligned) 

267 output_lines.append(" ".join(row_cells)) 

268 

269 return "\n".join(output_lines) 

270 

271 

272def format_status( 

273 status: Literal["pass", "fail", "warning", "info", "pending"], 

274 message: str = "", 

275 use_symbols: bool = True, 

276) -> str: 

277 """Format a status message with optional symbol. 

278 

279 Args: 

280 status: Status type (pass, fail, warning, info, pending). 

281 message: Optional message text. 

282 use_symbols: Use Unicode symbols or text. 

283 

284 Returns: 

285 Formatted status string. 

286 

287 Example: 

288 >>> format_status("pass", "All tests passed") 

289 '✓ All tests passed' 

290 """ 

291 symbols = { 

292 "pass": "✓", 

293 "fail": "✗", 

294 "warning": "⚠", 

295 "info": "ℹ", # noqa: RUF001 - intentional unicode info symbol 

296 "pending": "⏳", 

297 } 

298 

299 colors: dict[str, ColorName] = { 

300 "pass": "green", 

301 "fail": "red", 

302 "warning": "yellow", 

303 "info": "blue", 

304 "pending": "cyan", 

305 } 

306 

307 # Get color with default, cast needed because dict.get default is str 

308 status_color: ColorName = colors.get(status, "white") # type: ignore[assignment] 

309 

310 if use_symbols: 

311 symbol = symbols.get(status, "•") 

312 colored_symbol = colorize(symbol, color=status_color) 

313 return f"{colored_symbol} {message}" if message else colored_symbol 

314 else: 

315 text = status.upper() 

316 return ( 

317 colorize(f"{text}: {message}", color=status_color) 

318 if message 

319 else colorize(text, color=status_color) 

320 ) 

321 

322 

323def format_percentage( 

324 value: float, 

325 decimals: int = 1, 

326 show_bar: bool = False, 

327 bar_width: int = 10, 

328) -> str: 

329 """Format a percentage with optional progress bar. 

330 

331 Args: 

332 value: Percentage value (0-100 or 0-1). 

333 decimals: Decimal places. 

334 show_bar: Include ASCII progress bar. 

335 bar_width: Width of progress bar. 

336 

337 Returns: 

338 Formatted percentage string. 

339 

340 Example: 

341 >>> format_percentage(0.75, show_bar=True) 

342 '75.0% [████████ ]' 

343 """ 

344 # Normalize to 0-100 range 

345 if value <= 1: 

346 pct = value * 100 

347 else: 

348 pct = value 

349 

350 result = f"{pct:.{decimals}f}%" 

351 

352 if show_bar and 0 <= pct <= 100: 

353 filled = int((pct / 100) * bar_width) 

354 bar = "[" + "█" * filled + "░" * (bar_width - filled) + "]" 

355 result = f"{result} {bar}" 

356 

357 return result 

358 

359 

360def format_duration(seconds: float) -> str: 

361 """Format duration in seconds as human-readable string. 

362 

363 Args: 

364 seconds: Duration in seconds. 

365 

366 Returns: 

367 Human-readable duration (e.g., "1h 23m 45s"). 

368 

369 Example: 

370 >>> format_duration(5025) 

371 '1h 23m 45s' 

372 """ 

373 if seconds < 0: 

374 return "invalid" 

375 

376 hours = int(seconds // 3600) 

377 minutes = int((seconds % 3600) // 60) 

378 secs = int(seconds % 60) 

379 millis = int((seconds % 1) * 1000) 

380 

381 if hours > 0: 

382 return f"{hours}h {minutes}m {secs}s" 

383 elif minutes > 0: 

384 return f"{minutes}m {secs}s" 

385 elif secs > 0: 

386 return f"{secs}s" 

387 else: 

388 return f"{millis}ms" 

389 

390 

391def format_size(bytes_value: int, precision: int = 2) -> str: 

392 """Format byte size as human-readable string. 

393 

394 Args: 

395 bytes_value: Size in bytes. 

396 precision: Decimal precision. 

397 

398 Returns: 

399 Human-readable size (e.g., "1.23 MB"). 

400 

401 Example: 

402 >>> format_size(1234567) 

403 '1.18 MB' 

404 """ 

405 units = ["B", "KB", "MB", "GB", "TB"] 

406 size = float(bytes_value) 

407 

408 for unit in units: 408 ↛ 413line 408 didn't jump to line 413 because the loop on line 408 didn't complete

409 if size < 1024: 

410 return f"{size:.{precision}f} {unit}" 

411 size /= 1024 

412 

413 return f"{size:.{precision}f} PB" 

414 

415 

416def format_list( 

417 items: list[str], 

418 style: Literal["bullet", "numbered", "comma", "newline"] = "bullet", 

419 prefix: str = "", 

420) -> str: 

421 """Format a list of items with various styles. 

422 

423 Args: 

424 items: List of items to format. 

425 style: Formatting style (bullet, numbered, comma, newline). 

426 prefix: Prefix for each item. 

427 

428 Returns: 

429 Formatted list as string. 

430 

431 Example: 

432 >>> format_list(["a", "b", "c"], style="bullet") 

433 ' • a\\n • b\\n • c' 

434 """ 

435 if not items: 

436 return "" 

437 

438 if style == "bullet": 

439 return "\n".join(f"{prefix}{item}" for item in items) 

440 elif style == "numbered": 

441 return "\n".join(f"{prefix}{i}. {item}" for i, item in enumerate(items, 1)) 

442 elif style == "comma": 

443 return ", ".join(items) 

444 else: # newline 

445 return "\n".join(items) 

446 

447 

448def format_key_value_pairs( 

449 pairs: dict[str, Any], 

450 indent: int = 2, 

451 separator: str = ": ", 

452) -> str: 

453 """Format key-value pairs with indentation. 

454 

455 Args: 

456 pairs: Dictionary of key-value pairs. 

457 indent: Indentation level (spaces). 

458 separator: Separator between key and value. 

459 

460 Returns: 

461 Formatted key-value pairs as string. 

462 

463 Example: 

464 >>> format_key_value_pairs({"name": "Alice", "age": 30}) 

465 ' name: Alice\\n age: 30' 

466 """ 

467 if not pairs: 

468 return "" 

469 

470 indent_str = " " * indent 

471 lines = [] 

472 for key, value in pairs.items(): 

473 lines.append(f"{indent_str}{key}{separator}{value}") 

474 return "\n".join(lines) 

475 

476 

477def format_code_block( 

478 code: str, 

479 line_numbers: bool = False, 

480 indent: int = 0, 

481 language: str | None = None, 

482) -> str: 

483 """Format code with optional line numbers and indentation. 

484 

485 Args: 

486 code: Code content. 

487 line_numbers: Include line numbers. 

488 indent: Indentation level. 

489 language: Programming language for syntax highlighting (currently unused). 

490 

491 Returns: 

492 Formatted code block. 

493 

494 Example: 

495 >>> format_code_block("x = 1\\nprint(x)", line_numbers=True) 

496 """ 

497 indent_str = " " * indent 

498 lines = code.split("\n") 

499 

500 if line_numbers: 

501 max_num_width = len(str(len(lines))) 

502 formatted_lines = [] 

503 for num, line in enumerate(lines, 1): 

504 formatted_lines.append(f"{indent_str}{num:>{max_num_width}} | {line}") 

505 return "\n".join(formatted_lines) 

506 else: 

507 return "\n".join(f"{indent_str}{line}" for line in lines) 

508 

509 

510__all__ = [ 

511 "Color", 

512 "FormattedText", 

513 "TextAlignment", 

514 "align_text", 

515 "colorize", 

516 "format_code_block", 

517 "format_duration", 

518 "format_key_value_pairs", 

519 "format_list", 

520 "format_percentage", 

521 "format_size", 

522 "format_status", 

523 "format_table", 

524 "format_text", 

525 "truncate", 

526]