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

48 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-28 13:07 -0500

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

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import shutil 

8import sys 

9import textwrap 

10from typing import TYPE_CHECKING, Any 

11 

12if TYPE_CHECKING: 

13 from little_loops.config import CliConfig 

14 

15 

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

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

18 

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

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

21 """ 

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

23 return size.columns, size.lines 

24 

25 

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

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

28 return terminal_size(default_cols=default)[0] 

29 

30 

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

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

33 w = width or terminal_width() 

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

35 

36 

37# --------------------------------------------------------------------------- 

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

39# --------------------------------------------------------------------------- 

40 

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

42 

43PRIORITY_COLOR: dict[str, str] = { 

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

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

46 "P2": "33", 

47 "P3": "0", 

48 "P4": "2", 

49 "P5": "2", 

50} 

51TYPE_COLOR: dict[str, str] = { 

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

53 "FEAT": "32", 

54 "ENH": "34", 

55 "EPIC": "35", 

56} 

57 

58 

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

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

61 

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

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

64 

65 Args: 

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

67 """ 

68 global _USE_COLOR, PRIORITY_COLOR, TYPE_COLOR 

69 

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

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

72 

73 if config is None: 

74 _USE_COLOR = sys.stdout.isatty() and not no_color_env 

75 return 

76 

77 _USE_COLOR = config.color and sys.stdout.isatty() and not no_color_env 

78 

79 # Merge custom priority colors 

80 PRIORITY_COLOR.update( 

81 { 

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

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

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

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

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

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

88 } 

89 ) 

90 

91 # Merge custom type colors 

92 TYPE_COLOR.update( 

93 { 

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

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

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

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

98 } 

99 ) 

100 

101 

102def use_color_enabled() -> bool: 

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

104 return _USE_COLOR 

105 

106 

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

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

109 if not _USE_COLOR: 

110 return text 

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

112 

113 

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

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

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

117 

118 

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

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

121 total = int(seconds) 

122 if total < 60: 

123 return f"{total}s ago" 

124 if total < 3600: 

125 m, s = divmod(total, 60) 

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

127 if total < 86400: 

128 h, rem = divmod(total, 3600) 

129 m = rem // 60 

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

131 d, rem = divmod(total, 86400) 

132 h = rem // 3600 

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