Coverage for little_loops / cli / output.py: 31%

135 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -0500

1"""Shared CLI output utilities: terminal width, text wrapping, and ANSI color.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import re 

8import shutil 

9import sys 

10import textwrap 

11from typing import TYPE_CHECKING, Any, Literal 

12 

13if TYPE_CHECKING: 

14 from little_loops.config import CliConfig 

15 

16 

17def terminal_size(default_cols: int = 80, default_rows: int = 24) -> tuple[int, int]: 

18 """Return ``(cols, rows)`` from ``shutil.get_terminal_size``. 

19 

20 Use this when layout needs both dimensions (e.g. pinned-pane decisions in 

21 alt-screen mode). For column-only needs, prefer :func:`terminal_width`. 

22 """ 

23 size = shutil.get_terminal_size((default_cols, default_rows)) 

24 return size.columns, size.lines 

25 

26 

27def terminal_width(default: int = 80) -> int: 

28 """Return the current terminal column width, falling back to *default*.""" 

29 return terminal_size(default_cols=default)[0] 

30 

31 

32def wrap_text(text: str, indent: str = " ", width: int | None = None) -> str: 

33 """Wrap *text* at terminal width with consistent *indent* on every line.""" 

34 w = width or terminal_width() 

35 return textwrap.fill(text, width=w, initial_indent=indent, subsequent_indent=indent) 

36 

37 

38# --------------------------------------------------------------------------- 

39# ANSI escape-sequence stripping 

40# --------------------------------------------------------------------------- 

41 

42_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") 

43 

44 

45def strip_ansi(text: str) -> str: 

46 """Strip ANSI escape sequences from *text* and return plain text.""" 

47 return _ANSI_RE.sub("", text) 

48 

49 

50# --------------------------------------------------------------------------- 

51# Box-drawing character constants (Unicode box-drawing set) 

52# --------------------------------------------------------------------------- 

53 

54BOX_H = "─" # ─ 

55BOX_V = "│" # │ 

56BOX_TL = "┌" # ┌ 

57BOX_TR = "┐" # ┐ 

58BOX_BL = "└" # └ 

59BOX_BR = "┘" # ┘ 

60BOX_ML = "├" # ├ 

61BOX_MR = "┤" # ┤ 

62 

63 

64# --------------------------------------------------------------------------- 

65# ANSI color helpers — suppressed when NO_COLOR=1 or stdout is not a TTY 

66# --------------------------------------------------------------------------- 

67 

68_USE_COLOR: bool = os.environ.get("FORCE_COLOR", "") == "1" or ( 

69 sys.stdout.isatty() and os.environ.get("NO_COLOR", "") == "" 

70) 

71 

72PRIORITY_COLOR: dict[str, str] = { 

73 "P0": "38;5;208;1", 

74 "P1": "38;5;208", 

75 "P2": "33", 

76 "P3": "0", 

77 "P4": "2", 

78 "P5": "2", 

79} 

80TYPE_COLOR: dict[str, str] = { 

81 "BUG": "38;5;208", 

82 "FEAT": "32", 

83 "ENH": "34", 

84 "EPIC": "35", 

85} 

86 

87 

88def configure_output(config: CliConfig | None = None) -> None: 

89 """Apply CLI color configuration to module-level color state. 

90 

91 Call this once at startup after loading BRConfig. Updates _USE_COLOR, 

92 PRIORITY_COLOR, and TYPE_COLOR based on config and NO_COLOR env var. 

93 

94 Args: 

95 config: CliConfig from BRConfig.cli, or None for defaults. 

96 """ 

97 global _USE_COLOR, PRIORITY_COLOR, TYPE_COLOR 

98 

99 # NO_COLOR env var always takes precedence (industry convention) 

100 no_color_env = os.environ.get("NO_COLOR", "") != "" 

101 

102 # FORCE_COLOR=1 forces color even when stdout is not a TTY 

103 force_color = os.environ.get("FORCE_COLOR", "") == "1" 

104 

105 if config is None: 

106 _USE_COLOR = not no_color_env and (force_color or sys.stdout.isatty()) 

107 return 

108 

109 _USE_COLOR = config.color and not no_color_env and (force_color or sys.stdout.isatty()) 

110 

111 # Merge custom priority colors 

112 PRIORITY_COLOR.update( 

113 { 

114 "P0": config.colors.priority.P0, 

115 "P1": config.colors.priority.P1, 

116 "P2": config.colors.priority.P2, 

117 "P3": config.colors.priority.P3, 

118 "P4": config.colors.priority.P4, 

119 "P5": config.colors.priority.P5, 

120 } 

121 ) 

122 

123 # Merge custom type colors 

124 TYPE_COLOR.update( 

125 { 

126 "BUG": config.colors.type.BUG, 

127 "FEAT": config.colors.type.FEAT, 

128 "ENH": config.colors.type.ENH, 

129 "EPIC": config.colors.type.EPIC, 

130 } 

131 ) 

132 

133 

134def use_color_enabled() -> bool: 

135 """Return the current module-level color state set by configure_output().""" 

136 return _USE_COLOR 

137 

138 

139def colorize(text: str, code: str) -> str: 

140 """Wrap *text* in the given ANSI escape *code*, or return it unchanged.""" 

141 if not _USE_COLOR: 

142 return text 

143 return f"\033[{code}m{text}\033[0m" 

144 

145 

146def print_json(data: Any) -> None: 

147 """Print *data* as formatted JSON to stdout.""" 

148 print(json.dumps(data, indent=2)) 

149 

150 

151def format_relative_time(seconds: float) -> str: 

152 """Format seconds as a human-readable relative time string (e.g., '3m ago').""" 

153 total = int(seconds) 

154 if total < 60: 

155 return f"{total}s ago" 

156 if total < 3600: 

157 m, s = divmod(total, 60) 

158 return f"{m}m ago" if s == 0 else f"{m}m {s}s ago" 

159 if total < 86400: 

160 h, rem = divmod(total, 3600) 

161 m = rem // 60 

162 return f"{h}h ago" if m == 0 else f"{h}h {m}m ago" 

163 d, rem = divmod(total, 86400) 

164 h = rem // 3600 

165 return f"{d}d ago" if h == 0 else f"{d}d {h}h ago" 

166 

167 

168# --------------------------------------------------------------------------- 

169# User-facing message helpers — simple, untimestamped, icon-prefixed. 

170# Distinguished from Logger (logger.py) by: no timestamps, direct stdout/stderr, 

171# icons only when color is enabled. 

172# --------------------------------------------------------------------------- 

173 

174_ICONS: dict[str, str] = { 

175 "success": "✓", # ✓ 

176 "error": "✗", # ✗ 

177 "warning": "⚠", # ⚠ 

178 "info": "ℹ", # ℹ 

179 "hint": "›", # › 

180} 

181 

182 

183def success(msg: str) -> None: 

184 """Print a success message to stdout.""" 

185 icon = f"{_ICONS['success']} " if _USE_COLOR else "" 

186 print(f"{colorize(icon + msg, '32')}", flush=True) 

187 

188 

189def error(msg: str) -> None: 

190 """Print an error message to stderr.""" 

191 icon = f"{_ICONS['error']} " if _USE_COLOR else "" 

192 print(f"{colorize(icon + msg, '38;5;208')}", file=sys.stderr, flush=True) 

193 

194 

195def warning(msg: str) -> None: 

196 """Print a warning message to stdout.""" 

197 icon = f"{_ICONS['warning']} " if _USE_COLOR else "" 

198 print(f"{colorize(icon + msg, '33')}", flush=True) 

199 

200 

201def info(msg: str) -> None: 

202 """Print an informational message to stdout.""" 

203 icon = f"{_ICONS['info']} " if _USE_COLOR else "" 

204 print(f"{colorize(icon + msg, '36')}", flush=True) 

205 

206 

207def hint(msg: str) -> None: 

208 """Print a hint / dim message to stdout.""" 

209 icon = f"{_ICONS['hint']} " if _USE_COLOR else "" 

210 print(f"{colorize(icon + msg, '2')}", flush=True) 

211 

212 

213# --------------------------------------------------------------------------- 

214# Structured formatters — pure string-returning helpers 

215# --------------------------------------------------------------------------- 

216 

217 

218def table(headers: list[str], rows: list[list[str]], max_col_width: int = 40) -> str: 

219 """Return an auto-width box-drawn table string. 

220 

221 Column widths are the lesser of *max_col_width* and the longest value 

222 in each column. Values exceeding *max_col_width* are truncated. 

223 """ 

224 if not headers: 

225 return "" 

226 

227 ncols = len(headers) 

228 all_cells = [headers] + rows 

229 

230 col_widths: list[int] = [0] * ncols 

231 for row in all_cells: 

232 for i, cell in enumerate(row): 

233 if i < ncols: 

234 col_widths[i] = max(col_widths[i], len(cell)) 

235 

236 col_widths = [max(3, min(w, max_col_width)) for w in col_widths] 

237 

238 def _cell(text: str, width: int) -> str: 

239 if len(text) <= width: 

240 return text.ljust(width) 

241 return text[: width - 1] + "…" 

242 

243 def _sep(left: str, mid: str, right: str) -> str: 

244 parts = [BOX_H * w for w in col_widths] 

245 return left + mid.join(parts) + right 

246 

247 lines: list[str] = [] 

248 lines.append(_sep(BOX_TL, "┬", BOX_TR)) 

249 lines.append( 

250 BOX_V + BOX_V.join(_cell(h, w) for h, w in zip(headers, col_widths, strict=True)) + BOX_V 

251 ) 

252 lines.append(_sep(BOX_ML, "┼", BOX_MR)) 

253 

254 for row in rows: 

255 padded = [] 

256 for i in range(ncols): 

257 val = row[i] if i < len(row) else "" 

258 padded.append(_cell(val, col_widths[i])) 

259 lines.append(BOX_V + BOX_V.join(padded) + BOX_V) 

260 

261 lines.append(_sep(BOX_BL, "┴", BOX_BR)) 

262 

263 return "\n".join(lines) 

264 

265 

266def status_block(items: dict[str, str]) -> str: 

267 """Return aligned key-value pairs. 

268 

269 Keys are right-padded so values align. Returns empty string for an empty dict. 

270 """ 

271 if not items: 

272 return "" 

273 

274 max_key = max(len(k) for k in items) 

275 lines: list[str] = [] 

276 for key, value in items.items(): 

277 lines.append(f"{key.ljust(max_key)}: {value}") 

278 return "\n".join(lines) 

279 

280 

281def progress(current: int, total: int, width: int = 20) -> str: 

282 """Return a ``|####`` |`` progress bar of *width* columns.""" 

283 if width < 3: 

284 width = 3 

285 inner = width - 2 

286 

287 if total <= 0: 

288 filled = 0 

289 else: 

290 filled = max(0, min(inner, round(inner * current / total))) 

291 

292 return "|" + "#" * filled + " " * (inner - filled) + "|" 

293 

294 

295def sparkline(current: int, total: int, width: int = 16) -> str: 

296 """Return a Unicode block-character progress bar of *width* characters.""" 

297 if width < 1: 

298 width = 1 

299 if total <= 0: 

300 filled = 0 

301 else: 

302 filled = max(0, min(width, round(width * current / total))) 

303 return "█" * filled + "░" * (width - filled) 

304 

305 

306# --------------------------------------------------------------------------- 

307# Output mode toggling 

308# --------------------------------------------------------------------------- 

309 

310_OUTPUT_MODE: Literal["human", "json", "plain"] = "human" 

311 

312 

313def set_output_mode(mode: Literal["human", "json", "plain"]) -> None: 

314 """Set the global output mode for all formatters.""" 

315 global _OUTPUT_MODE 

316 _OUTPUT_MODE = mode 

317 

318 

319def get_output_mode() -> Literal["human", "json", "plain"]: 

320 """Return the current global output mode.""" 

321 return _OUTPUT_MODE