1"""Logging utilities for little-loops.

2

3 Provides colorized console output with timestamps for automation tools.

4"""

5

6 from __future__ import annotations

7

8 import os

9 import sys

10 from datetime import datetime

11 from typing import TYPE_CHECKING

12

13 if TYPE_CHECKING:

14 from little_loops.config import CliColorsConfig

15

16

17 class Logger:

18 """Simple logger with timestamps and colors.

19

20 Provides info, success, warning, and error logging methods with

21 optional colorized output for terminal display.

22

23 Attributes:

24 verbose: Whether to output messages (False silences all output)

25 use_color: Whether to use ANSI color codes

26 """

27

28 # ANSI color codes (defaults; may be overridden per-instance via colors param)

29 CYAN = "\033[36m"

30 GREEN = "\033[32m"

31 YELLOW = "\033[33m"

32 ORANGE = "\033[38;5;208m" # 256-color orange (replaces red for errors)

33 RED = "\033[38;5;208m" # alias kept for backwards compatibility

34 MAGENTA = "\033[35m"

35 GRAY = "\033[90m"

36 RESET = "\033[0m"

37

38 def __init__(

39 self,

40 verbose: bool = True,

41 use_color: bool | None = None,

42 colors: CliColorsConfig | None = None,

43 ) -> None:

44 """Initialize logger.

45

46 Args:

47 verbose: Whether to output messages

48 use_color: Whether to use ANSI color codes. Defaults to True unless

49 the NO_COLOR environment variable is set or stdout is not a TTY.

50 colors: Optional CliColorsConfig to override default color codes.

51 """

52 self.verbose = verbose

53 if use_color is None:

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

55 self.use_color = use_color

56

57 # Apply color overrides from config

58 if colors is not None:

59 self.CYAN = f"\033[{colors.logger.info}m"

60 self.GREEN = f"\033[{colors.logger.success}m"

61 self.YELLOW = f"\033[{colors.logger.warning}m"

62 self.ORANGE = f"\033[{colors.logger.error}m"

63 self.RED = self.ORANGE # keep alias in sync

64

65 def _timestamp(self) -> str:

66 """Get current timestamp string."""

67 return datetime.now().strftime("%H:%M:%S")

68

69 def _format(self, color: str, msg: str) -> str:

70 """Format message with timestamp and optional color."""

71 ts = self._timestamp()

72 if self.use_color:

73 return f"{color}[{ts}]{self.RESET} {msg}"

74 return f"[{ts}] {msg}"

75

76 def info(self, msg: str) -> None:

77 """Log an info message."""

78 if self.verbose:

79 print(self._format(self.CYAN, msg), flush=True)

80

81 def debug(self, msg: str) -> None:

82 """Log a debug message (gray/dim) to stderr."""

83 if self.verbose:

84 print(self._format(self.GRAY, msg), file=sys.stderr, flush=True)

85

86 def success(self, msg: str) -> None:

87 """Log a success message."""

88 if self.verbose:

89 print(self._format(self.GREEN, msg), flush=True)

90

91 def warning(self, msg: str) -> None:

92 """Log a warning message to stderr."""

93 if self.verbose:

94 print(self._format(self.YELLOW, msg), file=sys.stderr, flush=True)

95

96 def error(self, msg: str) -> None:

97 """Log an error message to stderr."""

98 if self.verbose:

99 print(self._format(self.RED, msg), file=sys.stderr, flush=True)

100

101 def timing(self, msg: str) -> None:

102 """Log timing information."""

103 if self.verbose:

104 print(self._format(self.MAGENTA, msg), flush=True)

105

106 def header(self, msg: str, char: str = "=", width: int = 60) -> None:

107 """Log a header with separators."""

108 if self.verbose:

109 line = char * width

110 print(line, flush=True)

111 print(msg, flush=True)

112 print(line, flush=True)

113

114

115 def format_duration(seconds: float) -> str:

116 """Format duration in human-readable form.

117

118 Args:

119 seconds: Duration in seconds

120

121 Returns:

122 Human-readable string like "5.2 seconds" or "3.5 minutes"

123 """

124 if seconds >= 60:

125 return f"{seconds / 60:.1f} minutes"

126 return f"{seconds:.1f} seconds"