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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""UI-specific formatting utilities for TraceKit displays.
3This module provides formatting functions tailored for user interface display,
4including text alignment, truncation, color codes, and structured output.
6- UI formatting for terminal and web outputs
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"""
18from __future__ import annotations
20from dataclasses import dataclass
21from enum import Enum
22from typing import Any, Literal
24# Type alias for color names accepted by colorize()
25type ColorName = Literal["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
28class Color(Enum):
29 """ANSI color codes for terminal output."""
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"
42class TextAlignment(Enum):
43 """Text alignment options."""
45 LEFT = "left"
46 CENTER = "center"
47 RIGHT = "right"
50@dataclass
51class FormattedText:
52 """Container for formatted text output.
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 """
61 content: str
62 color: Color | None = None
63 bold: bool = False
64 width: int = 0
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
76def colorize(
77 text: str,
78 color: ColorName = "white",
79 bold: bool = False,
80) -> str:
81 """Apply ANSI color codes to text.
83 Args:
84 text: Text to colorize.
85 color: Color name (black, red, green, yellow, blue, magenta, cyan, white).
86 bold: Apply bold formatting.
88 Returns:
89 Text with ANSI color codes.
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
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
107def truncate(
108 text: str,
109 max_length: int,
110 suffix: str = "...",
111) -> str:
112 """Truncate text to maximum length with suffix.
114 Args:
115 text: Text to truncate.
116 max_length: Maximum length including suffix.
117 suffix: Suffix to append if truncated (default "...").
119 Returns:
120 Truncated text.
122 Example:
123 >>> truncate("Very long text here", max_length=10)
124 'Very lon...'
125 """
126 if len(text) <= max_length:
127 return text
129 # Account for suffix length
130 truncate_at = max(0, max_length - len(suffix))
131 return text[:truncate_at] + suffix
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.
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.
148 Returns:
149 Aligned text.
151 Example:
152 >>> align_text("Hello", 10, "center")
153 ' Hello '
154 """
155 if len(text) >= width:
156 return text
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)
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.
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.
184 Returns:
185 Formatted text.
187 Example:
188 >>> format_text("Status", "active", align="left", width=20)
189 ' Status: active'
190 """
191 result = f"{label}{separator}{value}"
193 if color:
194 result = f"{label}{separator}{colorize(str(value), color=color)}"
196 if width:
197 result = align_text(result, width, alignment=align)
199 return result
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.
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.
216 Returns:
217 Formatted table as string.
219 Example:
220 >>> data = [["Alice", 85], ["Bob", 92]]
221 >>> format_table(data, headers=["Name", "Score"])
222 """
223 if not data:
224 return ""
226 # Calculate column widths
227 num_cols = len(data[0]) if data else 0
228 if headers:
229 num_cols = len(headers)
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)
242 # Default alignment
243 if align_columns is None:
244 align_columns = ["left"] * num_cols
246 output_lines = []
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)
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))
269 return "\n".join(output_lines)
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.
279 Args:
280 status: Status type (pass, fail, warning, info, pending).
281 message: Optional message text.
282 use_symbols: Use Unicode symbols or text.
284 Returns:
285 Formatted status string.
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 }
299 colors: dict[str, ColorName] = {
300 "pass": "green",
301 "fail": "red",
302 "warning": "yellow",
303 "info": "blue",
304 "pending": "cyan",
305 }
307 # Get color with default, cast needed because dict.get default is str
308 status_color: ColorName = colors.get(status, "white") # type: ignore[assignment]
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 )
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.
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.
337 Returns:
338 Formatted percentage string.
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
350 result = f"{pct:.{decimals}f}%"
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}"
357 return result
360def format_duration(seconds: float) -> str:
361 """Format duration in seconds as human-readable string.
363 Args:
364 seconds: Duration in seconds.
366 Returns:
367 Human-readable duration (e.g., "1h 23m 45s").
369 Example:
370 >>> format_duration(5025)
371 '1h 23m 45s'
372 """
373 if seconds < 0:
374 return "invalid"
376 hours = int(seconds // 3600)
377 minutes = int((seconds % 3600) // 60)
378 secs = int(seconds % 60)
379 millis = int((seconds % 1) * 1000)
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"
391def format_size(bytes_value: int, precision: int = 2) -> str:
392 """Format byte size as human-readable string.
394 Args:
395 bytes_value: Size in bytes.
396 precision: Decimal precision.
398 Returns:
399 Human-readable size (e.g., "1.23 MB").
401 Example:
402 >>> format_size(1234567)
403 '1.18 MB'
404 """
405 units = ["B", "KB", "MB", "GB", "TB"]
406 size = float(bytes_value)
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
413 return f"{size:.{precision}f} PB"
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.
423 Args:
424 items: List of items to format.
425 style: Formatting style (bullet, numbered, comma, newline).
426 prefix: Prefix for each item.
428 Returns:
429 Formatted list as string.
431 Example:
432 >>> format_list(["a", "b", "c"], style="bullet")
433 ' • a\\n • b\\n • c'
434 """
435 if not items:
436 return ""
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)
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.
455 Args:
456 pairs: Dictionary of key-value pairs.
457 indent: Indentation level (spaces).
458 separator: Separator between key and value.
460 Returns:
461 Formatted key-value pairs as string.
463 Example:
464 >>> format_key_value_pairs({"name": "Alice", "age": 30})
465 ' name: Alice\\n age: 30'
466 """
467 if not pairs:
468 return ""
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)
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.
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).
491 Returns:
492 Formatted code block.
494 Example:
495 >>> format_code_block("x = 1\\nprint(x)", line_numbers=True)
496 """
497 indent_str = " " * indent
498 lines = code.split("\n")
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)
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]