Coverage for fmt.py: 81%

1"""Terminal formatting helpers for Vibelexity output."""

2

3 from __future__ import annotations

4

5 import os

6 import re

7 import sys

8

9# ── Color support ─────────────────────────────────────────────────────

10# Auto-detect: color only when stdout is an interactive terminal.

11# Respect the NO_COLOR convention (https://no-color.org/).

12

13 _USE_COLOR: bool = sys.stdout.isatty() and not bool(os.environ.get("NO_COLOR"))

14

15 _RESET = "\033[0m"

16 _BOLD = "\033[1m"

17 _DIM = "\033[2m"

18

19 _CYAN = "\033[36m"

20 _GREEN = "\033[32m"

21 _YELLOW = "\033[33m"

22 _RED = "\033[31m"

23 _MAGENTA = "\033[35m"

24

25

26# ── Color wrappers ────────────────────────────────────────────────────

27

28

29 def _colorize(text: str, *codes: str) -> str:

30 """Wrap *text* in ANSI escape codes (no-op when color is disabled)."""

31 if not _USE_COLOR:

32 return text

33 return "".join(codes) + text + _RESET

34

35

36 def header(text: str) -> str:

37 return _colorize(text, _BOLD, _CYAN)

38

39

40 def label(text: str) -> str:

41 return _colorize(text, _YELLOW)

42

43

44 def num(value: str) -> str:

45 return _colorize(value, _BOLD, _GREEN)

46

47

48 def path(text: str) -> str:

49 return _colorize(text, _DIM, _MAGENTA)

50

51

52 def func_name(text: str) -> str:

53 return _colorize(text, _BOLD)

54

55

56 def warn(text: str) -> str:

57 return _colorize(text, _BOLD, _RED)

58

59

60 def dim(text: str) -> str:

61 """Format dimmed/muted text."""

62 return _colorize(text, _DIM)

63

64

65# ── Number formatting ─────────────────────────────────────────────────

66

67

68 def fmt_int(value: int | float) -> str:

69 """Format an integer with thousand separators.

70

71 Accepts float for convenience (truncates to int).

72 """

73 return f"{int(value):,}"

74

75

76 def fmt_float(value: float, decimals: int = 2) -> str:

77 """Format a float rounded to *decimals* places, with thousand separators."""

78 return f"{value:,.{decimals}f}"

79

80

81 def fmt_number(value: int | float) -> str:

82 """Auto-detect int vs float and format accordingly."""

83 if isinstance(value, int):

84 return fmt_int(value)

85 if value == int(value):

86 return fmt_int(value)

87 return fmt_float(value)

88

89

90# ── Composite helpers ─────────────────────────────────────────────────

91

92

93 def colored_num(value: int | float) -> str:

94 """Format and colorize a number."""

95 return num(fmt_number(value))

96

97

98 def colored_float(value: float, decimals: int = 2) -> str:

99 """Format and colorize a float."""

100 return num(fmt_float(value, decimals))

101

102

103 def colored_int(value: int | float) -> str:

104 """Format and colorize an integer."""

105 return num(fmt_int(value))

106

107

108 def rpad(value: int | float, width: int, num_formatter=None) -> str:

109 """Right-align a formatted number within *width* visible characters, then colorize.

110

111 Args:

112 value: Number to format

113 width: Minimum column width

114 num_formatter: Optional custom formatter (e.g., fmt_compact, colored_compact)

115 """

116 formatter = num_formatter or fmt_number

117 formatted = formatter(value)

118

119 # Check if the formatter returns a string with ANSI escape codes

120 has_ansi = bool(re.search(r"\x1b\[", formatted))

121

122 if has_ansi:

123 # Formatter already includes color codes - need to handle padding carefully

124 if not _USE_COLOR:

125 # Color disabled, strip ANSI codes and pad

126 text_only = re.sub(r"\x1b\[[0-9;]*m", "", formatted)

127 visible_len = len(text_only)

128 return text_only.rjust(width)

129 else:

130 # Color enabled, extract text for padding, preserve codes

131 text_only = re.sub(r"\x1b\[[0-9;]*m", "", formatted)

132 visible_len = len(text_only)

133 needed_spaces = max(0, width - visible_len)

134 return " " * needed_spaces + formatted

135

136 # Formatter returns plain text (no color yet)

137 if not _USE_COLOR:

138 return formatted.rjust(width)

139

140 # Color enabled, pad then add color

141 plain = formatted

142 visible_len = len(plain)

143 needed_spaces = max(0, width - visible_len)

144 return " " * needed_spaces + num(plain)

145

146

147 def fmt_compact(value: int | float) -> str:

148 """Format large numbers (5+ digits) with superscript notation.

149

150 Returns "∞" for values >= 10^6.

151 Uses superscript notation for values >= 100,000 and < 10^6.

152 Examples:

153 - 5,000,000+ → "∞"

154 - 123,456 → "123.5 × 10³"

155 - 99,999 → "99,999" (no compaction)

156 """

157 if value >= 1_000_000:

158 return "∞"

159 if value < 100_000:

160 return fmt_number(value)

161

162 is_int_input = isinstance(value, int)

163 scaled = value / 1_000

164 if is_int_input:

165 rounded = round(scaled, 1)

166 if abs(rounded - int(rounded)) < 0.01:

167 return f"{int(rounded)} × 10³"

168 return f"{scaled:.1f} × 10³"

169

170

171 def colored_compact(value: int | float) -> str:

172 """Format a number with compact notation and color."""

173 return num(fmt_compact(value))