Coverage for src / puzzletree / utils / logging.py: 83.62%

80 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-12 20:35 +0000

1"""Logging utilities for puzzletree CLI application.""" 

2 

3import os 

4import sys 

5from datetime import datetime, timezone 

6from logging import DEBUG, INFO, Formatter, Logger, getLogger 

7from logging.handlers import RotatingFileHandler 

8from pathlib import Path 

9from typing import cast 

10 

11from rich.console import Console 

12from rich.logging import RichHandler 

13 

14from puzzletree.config import Config 

15from puzzletree.utils.theme.theme import set_theme 

16 

17 

18def _is_running_in_pytest() -> bool: 

19 """Check if code is running inside pytest. 

20 

21 Returns: 

22 True if running in pytest, False otherwise 

23 """ 

24 # Check for pytest in sys.modules or environment variable 

25 # Also check if we're being imported during pytest collection 

26 return ( 

27 "pytest" in sys.modules 

28 or "PYTEST_CURRENT_TEST" in os.environ 

29 or any("pytest" in str(arg) for arg in sys.argv if isinstance(arg, str)) 

30 ) 

31 

32 

33def _set_up_logger( 

34 name: str = "puzzletree", 

35 console: Console | None = None, 

36 log_level: int | None = None, 

37 *, 

38 use_rotating_file_handler: bool = False, 

39 log_file_base_path: Path | None = None, 

40) -> Logger: 

41 """Setting up the logger.""" 

42 os.environ["NO_ALBUMENTATIONS_UPDATE"] = "1" 

43 if log_level is None: 

44 log_level = int(os.getenv("_FUN_LOG_LEVEL", DEBUG)) 

45 else: 

46 # Handle string log levels and invalid values gracefully 

47 try: 

48 if isinstance(log_level, str): 

49 log_level = int(log_level) 

50 except (ValueError, TypeError): 

51 # Fall back to default if conversion fails 

52 log_level = DEBUG 

53 

54 module_logger = getLogger(name) 

55 

56 if len(module_logger.handlers) > 0: 

57 for handler in module_logger.handlers: 57 ↛ 63line 57 didn't jump to line 63 because the loop on line 57 didn't complete

58 if handler.get_name() == "rich": 58 ↛ 57line 58 didn't jump to line 57 because the condition on line 58 was always true

59 # Set the log level even for existing loggers 

60 module_logger.setLevel(level=log_level) 

61 return module_logger 

62 

63 if not console: 63 ↛ 77line 63 didn't jump to line 77 because the condition on line 63 was always true

64 # In pytest, disable Rich formatting to avoid ANSI codes in test assertions 

65 if _is_running_in_pytest(): 

66 # Use a console that outputs plain text (no colors/formatting) 

67 # Write to stdout instead of stderr so CliRunner can capture it 

68 console = Console( 

69 file=sys.stdout, 

70 force_terminal=False, 

71 legacy_windows=False, 

72 no_color=True, 

73 ) 

74 else: 

75 console = Console(theme=set_theme("dark")) 

76 

77 rich_handler = RichHandler(rich_tracebacks=True, console=console) 

78 

79 rich_handler.set_name("rich") 

80 module_logger.addHandler(rich_handler) 

81 

82 if use_rotating_file_handler and log_file_base_path is not None: 

83 current_date = datetime.now(tz=timezone.utc).strftime("%Y_%m_%d") 

84 log_file_path = log_file_base_path / f"log_{current_date}.log" 

85 os.makedirs(os.path.dirname(log_file_path), exist_ok=True) 

86 

87 module_logger.setLevel(level=log_level) 

88 

89 # get any 

90 module_logger.propagate = False 

91 # external Logger levels 

92 

93 getLogger("PIL").setLevel(level=log_level) 

94 

95 return module_logger 

96 

97 

98def _attach_rotating_file_handler( 

99 logger: Logger, 

100 log_file: str, 

101 maximum_log_file_size_mb: int = 10, 

102 maximum_log_file_time_days: int = 3, 

103) -> Logger: 

104 """Attach a rotating file handler to the logger.""" 

105 handler = RotatingFileHandler( 

106 log_file, 

107 maxBytes=maximum_log_file_size_mb * 1024 * 1024, 

108 backupCount=maximum_log_file_time_days, 

109 ) 

110 handler.setFormatter(Formatter(Config().log_format)) 

111 handler.set_name("rotating_file_handler") 

112 logger.addHandler(handler) 

113 return logger 

114 

115 

116def get_logger_console( 

117 name: str = "puzzletree", 

118 console: Console | None = None, 

119 log_level: int | None = None, 

120) -> tuple[Logger, Console]: 

121 """Get logger and console. 

122 

123 Args: 

124 name (str, optional): _description_. Defaults to "puzzletree". 

125 console (Console | None, optional): _description_. Defaults to None. 

126 log_level (int, optional): _description_. Defaults to INFO. 

127 

128 Returns: 

129 tuple[Logger, Console]: _description_ 

130 """ 

131 if log_level is None: 

132 log_level = int(os.getenv("_FUN_LOG_LEVEL", INFO)) 

133 else: 

134 # Handle string log levels and invalid values gracefully 

135 try: 

136 if isinstance(log_level, str): 

137 log_level = int(log_level) 

138 except (ValueError, TypeError): 

139 # Fall back to default if conversion fails 

140 log_level = INFO 

141 root_logger = _set_up_logger( 

142 use_rotating_file_handler=True, 

143 log_level=log_level, 

144 ) 

145 

146 if name != "puzzletree": 

147 logger = getLogger(name) 

148 logger.handlers = root_logger.handlers 

149 logger.setLevel(level=root_logger.level) 

150 logger.propagate = False 

151 else: 

152 logger = root_logger 

153 if log_level != root_logger.level: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true

154 logger.warning(f"Log level changed from {root_logger.level} to {log_level}") 

155 logger.setLevel(level=log_level) 

156 

157 if len(root_logger.handlers) > 0: 157 ↛ 174line 157 didn't jump to line 174 because the condition on line 157 was always true

158 # Find the rich handler in the handlers list 

159 for handler in root_logger.handlers: 159 ↛ 174line 159 didn't jump to line 174 because the loop on line 159 didn't complete

160 if handler.get_name() == "rich": 160 ↛ 159line 160 didn't jump to line 159 because the condition on line 160 was always true

161 rich_handler: RichHandler = cast("RichHandler", handler) 

162 if _is_running_in_pytest(): 162 ↛ 170line 162 didn't jump to line 170 because the condition on line 162 was always true

163 rich_handler.console = Console( 

164 file=sys.stdout, 

165 force_terminal=False, 

166 legacy_windows=False, 

167 no_color=True, 

168 ) 

169 # use console from handler 

170 console = rich_handler.console 

171 return logger, console 

172 

173 # If no console was found and none was provided, create a new one 

174 if console is None: 

175 # In pytest, disable Rich formatting to avoid ANSI codes in test assertions 

176 if _is_running_in_pytest(): 

177 # Use a console that outputs plain text (no colors/formatting) 

178 # Write to stdout instead of stderr so CliRunner can capture it 

179 console = Console( 

180 file=sys.stdout, 

181 force_terminal=False, 

182 legacy_windows=False, 

183 no_color=True, 

184 ) 

185 else: 

186 console = Console() 

187 

188 return logger, console