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

29 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:20 -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_width(default: int = 80) -> int: 

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

18 return shutil.get_terminal_size((default, 24)).columns 

19 

20 

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

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

23 w = width or terminal_width() 

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

25 

26 

27# --------------------------------------------------------------------------- 

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

29# --------------------------------------------------------------------------- 

30 

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

32 

33PRIORITY_COLOR: dict[str, str] = { 

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

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

36 "P2": "33", 

37 "P3": "0", 

38 "P4": "2", 

39 "P5": "2", 

40} 

41TYPE_COLOR: dict[str, str] = { 

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

43 "FEAT": "32", 

44 "ENH": "34", 

45} 

46 

47 

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

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

50 

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

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

53 

54 Args: 

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

56 """ 

57 global _USE_COLOR, PRIORITY_COLOR, TYPE_COLOR 

58 

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

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

61 

62 if config is None: 

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

64 return 

65 

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

67 

68 # Merge custom priority colors 

69 PRIORITY_COLOR.update( 

70 { 

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

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

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

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

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

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

77 } 

78 ) 

79 

80 # Merge custom type colors 

81 TYPE_COLOR.update( 

82 { 

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

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

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

86 } 

87 ) 

88 

89 

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

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

92 if not _USE_COLOR: 

93 return text 

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

95 

96 

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

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

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