Coverage for src/setlogging/logger.py: 82%

103 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-22 02:34 +0000

1# Standard library imports 

2from datetime import datetime, timezone as dt_timezone 

3import json 

4import logging 

5from logging.handlers import RotatingFileHandler 

6import os 

7from typing import Optional, Union 

8 

9TIMEZONE=datetime.now().astimezone().tzinfo 

10 

11# class TimezoneFormatter(logging.Formatter): 

12# """ 

13# Custom formatter to include timezone-aware timestamps in log messages. 

14 

15# Args: 

16# fmt: The format string for the log message 

17# datefmt: The format string for the timestamp 

18# timezone: Optional specific timezone to use (defaults to local) 

19 

20# Example: 

21# formatter = TimezoneFormatter( 

22# fmt='%(asctime)s [%(timezone)s] %(levelname)s: %(message)s', 

23# datefmt='%Y-%m-%d %H:%M:%S' 

24# ) 

25# """ 

26 

27# def __init__( 

28# self, 

29# fmt: Optional[str] = None, 

30# datefmt: Optional[str] = None 

31# ) -> None: 

32# super().__init__(fmt, datefmt) 

33# self.local_timezone = TIMEZONE 

34 

35# def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str: 

36# try: 

37# local_dt = datetime.fromtimestamp( 

38# record.created, self.local_timezone) 

39# if datefmt: 

40# return local_dt.strftime(datefmt) 

41# else: 

42# return local_dt.isoformat() 

43# except Exception as e: 

44# raise RuntimeError(f"Failed to format time: {str(e)}") from e 

45 

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

47# # Add timezone name to log record 

48# record.timezone = str(self.local_timezone) 

49# return super().format(record) 

50 

51 

52class CustomFormatter(logging.Formatter): 

53 def formatTime(self, record, datefmt): 

54 try: 

55 # Ensure datefmt is not None to avoid string concatenation errors 

56 if datefmt is None: 

57 datefmt = "%Y-%m-%d %H:%M:%S" # Default time format 

58 

59 # Create a timezone-aware datetime object 

60 tz_aware_time = datetime.fromtimestamp(record.created, tz=TIMEZONE) 

61 

62 # Format the time with milliseconds 

63 formatted_time_with_ms = tz_aware_time.strftime(datefmt + ".%f") 

64 formatted_time = formatted_time_with_ms[:-3] # Truncate to 3 decimal places for milliseconds 

65 

66 # Get the timezone abbreviation (e.g., EST) 

67 timezone_abbr = tz_aware_time.strftime("%Z") 

68 

69 # Combine the formatted time, milliseconds, and timezone 

70 return f"{formatted_time} {timezone_abbr}" 

71 

72 except Exception as e: 

73 # Fallback to the parent class's default time formatting in case of errors 

74 return super().formatTime(record, datefmt) 

75 

76 

77def setup_logging( 

78 log_level: int = logging.DEBUG, 

79 log_file: Optional[str] = None, 

80 max_size_mb: int = 25, # 25MB 

81 backup_count: int = 7, 

82 console_output: bool = True, 

83 log_format: Optional[str] = None, 

84 date_format: Optional[str] = None, 

85 json_format: bool = False, 

86 indent: Optional[int] = None 

87) -> logging.Logger: 

88 """ 

89 Configure logging system with rotating file handler and optional console output. 

90 

91 Args: 

92 log_level: Logging level (default: DEBUG) 

93 log_file: Log file path (default: app.log or app_json.log if json_format is True) 

94 max_size_mb: Max log file size in MB before rotation (default: 25MB) 

95 backup_count: Number of backup files to keep (default: 7) 

96 console_output: Enable console logging (default: True) 

97 log_format: Custom log format string (optional) 

98 date_format: Custom date format string (optional) 

99 json_format: Flag to determine if log format should be JSON (default: False) 

100 indent: Indentation level for JSON output (default: None) 

101 """ 

102 try: 

103 if max_size_mb <= 0: 

104 raise ValueError("max_size_mb must be positive") 

105 if backup_count < 0: 

106 raise ValueError("backup_count must be non-negative") 

107 if indent is not None: 

108 if indent < 0: 

109 raise ValueError("indent must be non-negative") 

110 if not json_format: 

111 raise ValueError("indent parameter is only valid when json_format is True") 

112 

113 # Validate log level 

114 valid_levels = { 

115 logging.DEBUG, logging.INFO, logging.WARNING, 

116 logging.ERROR, logging.CRITICAL 

117 } 

118 if log_level not in valid_levels: 

119 raise ValueError(f"Invalid log level: {log_level}. Valid levels are: {valid_levels}") 

120 

121 # Validate the date_format 

122 if date_format: 

123 valid_codes = {"%Y", "%m", "%d", "%H", "%M", "%S", "%z", "%Z"} 

124 if not any(code in date_format for code in valid_codes): 

125 raise ValueError(f"Invalid date_format: {date_format} must contain at least one format code (e.g., %Y, %m, %H)") 

126 

127 # Validate the log_format 

128 if log_format: 

129 valid_codes = {"%(asctime)s", "%(levelname)s", "%(name)s", "%(message)s"} 

130 if not any(code in log_format for code in valid_codes): 

131 raise ValueError(f"Invalid log_format: {log_format} must contain at least one format code (e.g., %(asctime)s, %(levelname)s)") 

132 

133 # Calculate max file size in bytes 

134 max_bytes = max_size_mb * 1024 * 1024 

135 

136 # Set default log file if not provided 

137 log_file = log_file or ("app_json.log" if json_format else "app.log") 

138 

139 # Create log directory if it does not exist 

140 log_dir = os.path.dirname(log_file) 

141 if log_dir: # If log_dir is not empty 

142 os.makedirs(log_dir, exist_ok=True) # Create directory if it does not exist 

143 

144 # check if the directory is writable 

145 test_file = os.path.join(log_dir, ".permission_test") 

146 try: 

147 with open(test_file, "w") as f: 

148 f.write("test") 

149 os.remove(test_file) 

150 except IOError as e: 

151 raise PermissionError(f"Directory not writable: {log_dir}") from e 

152 

153 # Check if log file is writable 

154 if os.path.exists(log_file): 

155 if not os.access(log_file, os.W_OK): 

156 raise PermissionError(f"File not writable: {log_file}") 

157 

158 

159 except Exception as e: # Catch permission errors 

160 raise 

161 

162 

163 try: 

164 # Create logger 

165 logger = logging.getLogger(__name__) 

166 logger.setLevel(log_level) 

167 

168 # Clear existing handlers 

169 logger.handlers = [] 

170 

171 # Set up formatter 

172 if json_format: 

173 formatter = logging.Formatter(json.dumps({ 

174 "time": "%(asctime)s", 

175 "name": "%(name)s", 

176 "level": "%(levelname)s", 

177 "message": "%(message)s" 

178 }, indent=indent)) 

179 else: 

180 formatter = CustomFormatter( 

181 log_format or "%(asctime)s [%(levelname)s] [%(name)s] %(message)s", 

182 date_format or "%Y-%m-%d %H:%M:%S" 

183 ) 

184 

185 # Set up file handler 

186 file_handler = RotatingFileHandler( 

187 log_file, maxBytes=max_bytes, backupCount=backup_count) 

188 file_handler.setFormatter(formatter) 

189 logger.addHandler(file_handler) 

190 

191 # Set up console handler if enabled 

192 if console_output: 

193 console_handler = logging.StreamHandler() 

194 console_handler.setFormatter(formatter) 

195 logger.addHandler(console_handler) 

196 

197 # Generate configuration details using get_config_message 

198 config_message = get_config_message( 

199 log_level=log_level, 

200 file_handler=file_handler, 

201 max_size_mb=max_size_mb, 

202 backup_count=backup_count, 

203 console_output=console_output, 

204 json_format=json_format, # Adapt the format based on user preference 

205 indent=indent 

206 ) 

207 

208 # Log configuration details with respect to log_level 

209 if json_format: 

210 # Parse JSON as dictionary 

211 config_dict = json.loads(config_message) 

212 if log_level != 0: 

213 logger.log(log_level, {"Logging Configuration": config_dict}) 

214 else: 

215 logger.warning({"Logging Configuration": config_dict}) 

216 else: 

217 if log_level != 0: 

218 logger.log(log_level, ( 

219 f"Logging Configuration:\n" 

220 f"{config_message}" 

221 )) 

222 else: 

223 logger.warning(f"Logging Configuration:\n{config_message}") 

224 

225 return logger 

226 

227 except Exception as e: 

228 raise RuntimeError(f"Failed to set up logging: {str(e)}") from e 

229 

230 

231def get_config_message(log_level, file_handler, max_size_mb, backup_count, console_output, json_format=False, indent=None): 

232 processID = os.getpid() 

233 

234 if json_format: 

235 config_dict = { 

236 "Level": logging.getLevelName(log_level), 

237 "LogFile": file_handler.baseFilename, 

238 "MaxFileSizeMB": max_size_mb, 

239 "BackupCount": backup_count, 

240 "ConsoleOutput": console_output, 

241 "Timezone": str(TIMEZONE), 

242 "ProcessID": processID 

243 } 

244 return json.dumps(config_dict) 

245 else: 

246 return f""" 

247=============================== 

248 Logging Configuration 

249=============================== 

250Level : {logging.getLevelName(log_level)} 

251Log File : {file_handler.baseFilename} 

252Max File Size: {max_size_mb:.2f} MB 

253Backup Count : {backup_count} 

254Console Out : {console_output} 

255Timezone : {TIMEZONE} 

256ProcessID : {processID} 

257=============================== 

258""" 

259 

260 

261def get_logger( 

262 name: str = __name__, 

263 log_level: int = logging.DEBUG, 

264 log_file: Optional[str] = None, 

265 max_size_mb: int = 25, # 25MB 

266 backup_count: int = 7, 

267 console_output: bool = True, 

268 log_format: Optional[str] = None, 

269 date_format: Optional[str] = None, 

270 json_format: bool = False, 

271 indent: Optional[int] = None 

272) -> logging.Logger: 

273 """ 

274 Simplified function to set up logging and return a logger instance. 

275 

276 Args: 

277 name: Name of the logger. 

278 log_level: Logging level. 

279 log_file: Log file name. 

280 max_size_mb: Max size of log file in MB before rotation. 

281 backup_count: Number of rotated backups to keep. 

282 console_output: Enable console logging (default: True) 

283 log_format: Custom log format string (optional) 

284 date_format: Custom date format string (optional) 

285 json_format: Flag to determine if log format should be JSON. 

286 indent: Indentation level for JSON output. 

287 

288 Returns: 

289 logging.Logger: Configured logger instance. 

290 """ 

291 return setup_logging( 

292 log_level=log_level, 

293 log_file=log_file, 

294 max_size_mb=max_size_mb, # Pass max_size_mb parameter 

295 backup_count=backup_count, 

296 console_output=console_output, 

297 log_format=log_format, 

298 date_format=date_format, 

299 json_format=json_format, 

300 indent=indent 

301 ) 

302 

303 

304# Example Usage 

305if __name__ == "__main__": 

306 try: 

307 logger = get_logger(console_output=True) 

308 logger.debug("Basic debug example") 

309 logger.info("Basic usage example") 

310 logger.info(datetime.now().astimezone().tzinfo) 

311 # JSON format example 

312 json_logger = get_logger(json_format=True, indent=2) 

313 json_logger.info("JSON format example") 

314 

315 

316 except Exception as e: 

317 print(f"Error: {str(e)}") 

318 raise