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
« 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.
4"""Collect functions related to output."""
6import logging
7import logging.config
8import sys
9from typing import Any, ClassVar, Literal
11from colorama import Fore, Style, just_fix_windows_console
12from termcolor import colored
14just_fix_windows_console() # needed on Windows
16LOG_LEVELS = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
19def error(text: str) -> None:
20 """Output a red/bold line on stderr."""
21 print(colored(text, "red", attrs=["bold"]), file=sys.stderr)
24class ColorFormatter(logging.Formatter):
25 """Base class for color-based formatters."""
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
33class LevelColorFormatter(ColorFormatter):
34 """Formatter that colors the log level."""
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 }
44 def format(self, record: logging.LogRecord) -> str: # pragma: no cover
45 if not self.use_colors:
46 return super().format(record)
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
57class BoldFormatter(ColorFormatter):
58 """Formatter that outputs all messages in bold, if colors are used."""
60 def format(self, record: logging.LogRecord) -> str: # pragma: no cover
61 if not self.use_colors:
62 return super().format(record)
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
72class CommandFormatter(logging.Formatter):
73 """Formatter that prepends any log message with a '+ ' (same as `set -x` on a shell)."""
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
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"
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 }
139 logging.config.dictConfig(config)