Coverage for structured_tutorials / output.py: 100%

28 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-22 22:55 +0100

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Collect functions related to output.""" 

5 

6import logging 

7import logging.config 

8import sys 

9from typing import Any, ClassVar, Literal 

10 

11from colorama import Fore, Style, just_fix_windows_console 

12from termcolor import colored 

13 

14just_fix_windows_console() # needed on Windows 

15 

16LOG_LEVELS = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 

17 

18 

19def error(text: str) -> None: 

20 """Output a red/bold line on stderr.""" 

21 print(colored(text, "red", attrs=["bold"]), file=sys.stderr) 

22 

23 

24class ColorFormatter(logging.Formatter): 

25 """Base class for color-based formatters.""" 

26 

27 def __init__(self, *args: Any, no_colors: bool = False, **kwargs: Any) -> None: 

28 super().__init__(*args, **kwargs) 

29 # Decide once at formatter creation time 

30 self.use_colors = sys.stderr.isatty() and no_colors is False 

31 

32 

33class LevelColorFormatter(ColorFormatter): 

34 """Formatter that colors the log level.""" 

35 

36 COLORS: ClassVar[dict[int, str]] = { 

37 logging.DEBUG: Fore.CYAN, 

38 logging.INFO: Fore.GREEN, 

39 logging.WARNING: Fore.YELLOW, 

40 logging.ERROR: Fore.RED, 

41 logging.CRITICAL: Fore.MAGENTA, 

42 } 

43 

44 def format(self, record: logging.LogRecord) -> str: # pragma: no cover 

45 if not self.use_colors: 

46 return super().format(record) 

47 

48 level_name = record.levelname 

49 color = self.COLORS.get(record.levelno, "") 

50 record.levelname = f"{color}{level_name.ljust(8)}{Style.RESET_ALL}" 

51 try: 

52 return super().format(record) 

53 finally: 

54 record.levelname = level_name # restore for other handlers 

55 

56 

57class BoldFormatter(ColorFormatter): 

58 """Formatter that outputs all messages in bold, if colors are used.""" 

59 

60 def format(self, record: logging.LogRecord) -> str: # pragma: no cover 

61 if not self.use_colors: 

62 return super().format(record) 

63 

64 original = record.msg 

65 record.msg = f"{Style.BRIGHT}{original}{Style.RESET_ALL}" 

66 try: 

67 return super().format(record) 

68 finally: 

69 record.msg = original # restore for other handlers 

70 

71 

72class CommandFormatter(logging.Formatter): 

73 """Formatter that prepends any log message with a '+ ' (same as `set -x` on a shell).""" 

74 

75 def format(self, record: logging.LogRecord) -> str: 

76 original = record.msg 

77 record.msg = f"+ {original}" 

78 try: 

79 return super().format(record) 

80 finally: 

81 record.msg = original # restore for other handlers 

82 

83 

84def setup_logging(level: LOG_LEVELS, no_colors: bool, show_commands: bool) -> None: 

85 """Setup logging for the process.""" 

86 command_log_level: LOG_LEVELS = "INFO" if show_commands else "WARNING" 

87 

88 config = { 

89 "version": 1, 

90 "disable_existing_loggers": False, 

91 "formatters": { 

92 "colored": { 

93 "()": "structured_tutorials.output.LevelColorFormatter", 

94 "format": "%(levelname)-8s | %(message)s", 

95 "no_colors": no_colors, 

96 }, 

97 "bold": { 

98 "()": "structured_tutorials.output.BoldFormatter", 

99 "format": "%(message)s", 

100 "no_colors": no_colors, 

101 }, 

102 "command": { 

103 "()": "structured_tutorials.output.CommandFormatter", 

104 "format": "%(message)s", 

105 }, 

106 }, 

107 "handlers": { 

108 "console": { 

109 "class": "logging.StreamHandler", 

110 "formatter": "colored", 

111 }, 

112 "part": { 

113 "class": "logging.StreamHandler", 

114 "formatter": "bold", 

115 }, 

116 "command": { 

117 "class": "logging.StreamHandler", 

118 "formatter": "command", 

119 }, 

120 }, 

121 "loggers": { 

122 "part": { 

123 "handlers": ["part"], 

124 "propagate": False, 

125 "level": level, 

126 }, 

127 "command": { 

128 "handlers": ["command"], 

129 "propagate": False, 

130 "level": command_log_level, 

131 }, 

132 }, 

133 "root": { 

134 "level": level, 

135 "handlers": ["console"], 

136 }, 

137 } 

138 

139 logging.config.dictConfig(config)