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

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

2

3 from __future__ import annotations

4

5 import json

6 import os

7 import shutil

8 import sys

9 import textwrap

10 from typing import TYPE_CHECKING, Any

11

12 if TYPE_CHECKING:

13 from little_loops.config import CliConfig

14

15

16 def 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

21 def 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

33 PRIORITY_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}

41 TYPE_COLOR: dict[str, str] = {

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

43 "FEAT": "32",

44 "ENH": "34",

45}

46

47

48 def 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

90 def 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

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

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

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