Coverage for src/sensai/util/logging.py: 39%

185 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-13 22:17 +0000

1import atexit 

2import logging as lg 

3import sys 

4import time 

5from datetime import datetime 

6from io import StringIO 

7from logging import * 

8from typing import List, Callable, Optional, TypeVar, TYPE_CHECKING 

9 

10from .time import format_duration 

11 

12if TYPE_CHECKING: 

13 import pandas as pd 

14 

15 

16log = getLogger(__name__) 

17 

18LOG_DEFAULT_FORMAT = '%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s' 

19T = TypeVar("T") 

20 

21# Holds the log format that is configured by the user (using function `configure`), such 

22# that it can be reused in other places 

23_logFormat = LOG_DEFAULT_FORMAT 

24 

25# User-configured callback which is called after logging is configured via function `configure` 

26_configureCallbacks: List[Callable[[], None]] = [] 

27 

28 

29def remove_log_handlers(): 

30 """ 

31 Removes all current log handlers 

32 """ 

33 logger = getLogger() 

34 while logger.hasHandlers(): 

35 logger.removeHandler(logger.handlers[0]) 

36 

37 

38def remove_log_handler(handler): 

39 getLogger().removeHandler(handler) 

40 

41 

42def is_log_handler_active(handler): 

43 return handler in getLogger().handlers 

44 

45 

46def is_enabled() -> bool: 

47 """ 

48 :return: True if logging is enabled (at least one log handler exists) 

49 """ 

50 return getLogger().hasHandlers() 

51 

52 

53def set_configure_callback(callback: Callable[[], None], append: bool = True) -> None: 

54 """ 

55 Configures a function to be called when logging is configured, e.g. through :func:`configure, :func:`run_main` or 

56 :func:`run_cli`. 

57 A typical use for the callback is to configure the logging behaviour of packages, setting appropriate log levels. 

58 

59 :param callback: the function to call 

60 :param append: whether to append to the list of callbacks; if False, any existing callbacks will be removed 

61 """ 

62 global _configureCallbacks 

63 if not append: 

64 _configureCallbacks = [] 

65 _configureCallbacks.append(callback) 

66 

67 

68# noinspection PyShadowingBuiltins 

69def configure(format=LOG_DEFAULT_FORMAT, level=lg.DEBUG): 

70 """ 

71 Configures logging to stdout with the given format and log level, 

72 also configuring the default log levels of some overly verbose libraries as well as some pandas output options. 

73 

74 :param format: the log format 

75 :param level: the minimum log level 

76 """ 

77 global _logFormat 

78 _logFormat = format 

79 remove_log_handlers() 

80 basicConfig(level=level, format=format, stream=sys.stdout) 

81 getLogger("matplotlib").setLevel(lg.INFO) 

82 getLogger("urllib3").setLevel(lg.INFO) 

83 getLogger("msal").setLevel(lg.INFO) 

84 try: 

85 import pandas as pd 

86 pd.set_option('display.max_colwidth', 255) 

87 except ImportError: 

88 pass 

89 for callback in _configureCallbacks: 

90 callback() 

91 

92 

93# noinspection PyShadowingBuiltins 

94def run_main(main_fn: Callable[..., T], format=LOG_DEFAULT_FORMAT, level=lg.DEBUG) -> T: 

95 """ 

96 Configures logging with the given parameters, ensuring that any exceptions that occur during 

97 the execution of the given function are logged. 

98 Logs two additional messages, one before the execution of the function, and one upon its completion. 

99 

100 :param main_fn: the function to be executed 

101 :param format: the log message format 

102 :param level: the minimum log level 

103 :return: the result of `main_fn` 

104 """ 

105 configure(format=format, level=level) 

106 log.info("Starting") 

107 try: 

108 result = main_fn() 

109 log.info("Done") 

110 return result 

111 except Exception as e: 

112 log.error("Exception during script execution", exc_info=e) 

113 

114 

115# noinspection PyShadowingBuiltins 

116def run_cli(main_fn: Callable[..., T], format: str = LOG_DEFAULT_FORMAT, level: int = lg.DEBUG) -> Optional[T]: 

117 """ 

118 Configures logging with the given parameters and runs the given main function as a 

119 CLI using `jsonargparse` (which is configured to also parse attribute docstrings, such 

120 that dataclasses can be used as function arguments). 

121 Using this function requires that `jsonargparse` and `docstring_parser` be available. 

122 Like `run_main`, two additional log messages will be logged (at the beginning and end 

123 of the execution), and it is ensured that all exceptions will be logged. 

124 

125 :param main_fn: the function to be executed 

126 :param format: the log message format 

127 :param level: the minimum log level 

128 :return: the result of `main_fn` 

129 """ 

130 from jsonargparse import set_docstring_parse_options, CLI 

131 

132 set_docstring_parse_options(attribute_docstrings=True) 

133 return run_main(lambda: CLI(main_fn), format=format, level=level) 

134 

135 

136def datetime_tag() -> str: 

137 """ 

138 :return: a string tag for use in log file names which contains the current date and time (compact but readable) 

139 """ 

140 return datetime.now().strftime('%Y%m%d-%H%M%S') 

141 

142 

143_fileLoggerPaths: List[str] = [] 

144_isAtExitReportFileLoggerRegistered = False 

145_memoryLogStream: Optional[StringIO] = None 

146 

147 

148def _at_exit_report_file_logger(): 

149 for path in _fileLoggerPaths: 

150 print(f"A log file was saved to {path}") 

151 

152 

153def add_file_logger(path, register_atexit=True): 

154 global _isAtExitReportFileLoggerRegistered 

155 log.info(f"Logging to {path} ...") 

156 handler = FileHandler(path) 

157 handler.setFormatter(Formatter(_logFormat)) 

158 Logger.root.addHandler(handler) 

159 _fileLoggerPaths.append(path) 

160 if not _isAtExitReportFileLoggerRegistered and register_atexit: 

161 atexit.register(_at_exit_report_file_logger) 

162 _isAtExitReportFileLoggerRegistered = True 

163 return handler 

164 

165 

166def add_memory_logger() -> None: 

167 """ 

168 Enables in-memory logging (if it is not already enabled), i.e. all log statements are written to a memory buffer and can later be 

169 read via function `get_memory_log()` 

170 """ 

171 global _memoryLogStream 

172 if _memoryLogStream is not None: 

173 return 

174 _memoryLogStream = StringIO() 

175 handler = StreamHandler(_memoryLogStream) 

176 handler.setFormatter(Formatter(_logFormat)) 

177 Logger.root.addHandler(handler) 

178 

179 

180def get_memory_log(): 

181 return _memoryLogStream.getvalue() 

182 

183 

184class StopWatch: 

185 """ 

186 Represents a stop watch for timing an execution. Constructing an instance starts the stopwatch. 

187 """ 

188 def __init__(self, start=True): 

189 self.start_time = time.time() 

190 self._elapsed_secs = 0.0 

191 self.is_running = start 

192 

193 def reset(self, start=True): 

194 """ 

195 Resets the stopwatch, setting the elapsed time to zero. 

196 

197 :param start: whether to start the stopwatch immediately 

198 """ 

199 self.start_time = time.time() 

200 self._elapsed_secs = 0.0 

201 self.is_running = start 

202 

203 def restart(self): 

204 """ 

205 Resets the stopwatch (setting the elapsed time to zero) and restarts it immediately 

206 """ 

207 self.reset(start=True) 

208 

209 def _get_elapsed_time_since_last_start(self): 

210 if self.is_running: 

211 return time.time() - self.start_time 

212 else: 

213 return 0 

214 

215 def pause(self): 

216 """ 

217 Pauses the stopwatch. It can be resumed via method 'resume'. 

218 """ 

219 self._elapsed_secs += self._get_elapsed_time_since_last_start() 

220 self.is_running = False 

221 

222 def resume(self): 

223 """ 

224 Resumes the stopwatch (assuming that it is currently paused). If the stopwatch is not paused, 

225 the method has no effect (and a warning is logged). 

226 """ 

227 if not self.is_running: 

228 self.start_time = time.time() 

229 self.is_running = True 

230 else: 

231 log.warning("Stopwatch is already running (resume has not effect)") 

232 

233 def get_elapsed_time_secs(self) -> float: 

234 """ 

235 Gets the total elapsed time, in seconds, on this stopwatch. 

236 

237 :return: the elapsed time in seconds 

238 """ 

239 return self._elapsed_secs + self._get_elapsed_time_since_last_start() 

240 

241 def get_elapsed_timedelta(self) -> "pd.Timedelta": 

242 """ 

243 :return: the elapsed time as a pandas.Timedelta object 

244 """ 

245 return pd.Timedelta(self.get_elapsed_time_secs(), unit="s") 

246 

247 def get_elapsed_time_string(self) -> str: 

248 """ 

249 :return: a string representation of the elapsed time 

250 """ 

251 secs = self.get_elapsed_time_secs() 

252 if secs < 60: 

253 return f"{secs:.3f} seconds" 

254 else: 

255 try: 

256 import pandas as pd 

257 return str(pd.Timedelta(secs, unit="s")) 

258 except ImportError: 

259 return format_duration(secs) 

260 

261 

262class StopWatchManager: 

263 """ 

264 A singleton which manages a pool of named stopwatches, such that executions to be timed by referring to a name only - 

265 without the need for a limited scope. 

266 """ 

267 _instance = None 

268 

269 @classmethod 

270 def get_instance(cls): 

271 if cls._instance is None: 

272 cls._instance = StopWatchManager(42) 

273 return cls._instance 

274 

275 def __init__(self, secret): 

276 if secret != 42: 

277 raise Exception("Use only the singleton instance via getInstance") 

278 self._stopWatches = {} 

279 

280 def start(self, name): 

281 self._stopWatches[name] = time.time() 

282 

283 def stop(self, name) -> float: 

284 """ 

285 :param name: the name of the stopwatch 

286 :return: the time that has passed in seconds 

287 """ 

288 time_passed_secs = time.time() - self._stopWatches[name] 

289 del self._stopWatches[name] 

290 return time_passed_secs 

291 

292 def is_running(self, name): 

293 return name in self._stopWatches 

294 

295 

296class LogTime: 

297 """ 

298 An execution time logger which can be conveniently applied using a with-statement - in order to log the executing time of the respective 

299 with-block. 

300 """ 

301 

302 def __init__(self, name, enabled=True, logger: Logger = None): 

303 """ 

304 :param name: the name of the event whose time is to be logged upon completion as "<name> completed in <time>" 

305 :param enabled: whether the logging is actually enabled; can be set to False to disable logging without necessitating 

306 changes to client code 

307 :param logger: the logger to use; if None, use the logger of LogTime's module 

308 """ 

309 self.name = name 

310 self.enabled = enabled 

311 self.stopwatch = None 

312 self.logger = logger if logger is not None else log 

313 

314 def start(self): 

315 """ 

316 Starts the stopwatch 

317 """ 

318 self.stopwatch = StopWatch() 

319 if self.enabled: 

320 self.logger.info(f"{self.name} starting ...") 

321 

322 def stop(self): 

323 """ 

324 Stops the stopwatch and logs the time taken (if enabled) 

325 """ 

326 if self.stopwatch is not None and self.enabled: 

327 self.logger.info(f"{self.name} completed in {self.stopwatch.get_elapsed_time_string()}") 

328 

329 def __exit__(self, exc_type, exc_value, traceback): 

330 self.stop() 

331 

332 def __enter__(self): 

333 self.start() 

334 return self 

335 

336 

337class FileLoggerContext: 

338 """ 

339 A context handler to be used in conjunction with Python's `with` statement which enables file-based logging. 

340 """ 

341 def __init__(self, path: str, enabled=True): 

342 """ 

343 :param path: the path to the log file 

344 :param enabled: whether to actually perform any logging. 

345 This switch allows the with statement to be applied regardless of whether logging shall be enabled. 

346 """ 

347 self.enabled = enabled 

348 self.path = path 

349 self._log_handler = None 

350 

351 def __enter__(self): 

352 if self.enabled: 

353 self._log_handler = add_file_logger(self.path, register_atexit=False) 

354 

355 def __exit__(self, exc_type, exc_value, traceback): 

356 if self._log_handler is not None: 

357 remove_log_handler(self._log_handler) 

358 

359 

360class LoggingDisabledContext: 

361 """ 

362 A context manager that will temporarily disable logging 

363 """ 

364 def __init__(self, highest_level=CRITICAL): 

365 """ 

366 :param highest_level: the highest level to disable 

367 """ 

368 self._highest_level = highest_level 

369 self._previous_level = None 

370 

371 def __enter__(self): 

372 self._previous_level = lg.root.manager.disable 

373 lg.disable(self._highest_level) 

374 

375 def __exit__(self, exc_type, exc_value, traceback): 

376 lg.disable(self._previous_level) 

377 

378 

379# noinspection PyShadowingBuiltins 

380class FallbackHandler(Handler): 

381 """ 

382 A log handler which applies the given handler only if the root logger has no defined handlers (or no handlers other than 

383 this fallback handler itself) 

384 """ 

385 def __init__(self, handler: Handler, level=NOTSET): 

386 """ 

387 :param handler: the handler to which messages shall be passed on 

388 :param level: the minimum level to output; if NOTSET, pass on everything 

389 """ 

390 super().__init__(level=level) 

391 self._handler = handler 

392 

393 def emit(self, record): 

394 root_handlers = getLogger().handlers 

395 if len(root_handlers) == 0 or (len(root_handlers) == 1 and root_handlers[0] == self): 

396 self._handler.emit(record)