logger.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import enum
  2. import logging
  3. import logging.handlers
  4. import os
  5. import sys
  6. def to_bool(arg):
  7. '''
  8. Return a boolean value based on `arg`.
  9. '''
  10. if arg is None or isinstance(arg, bool):
  11. return arg
  12. if isinstance(arg, str):
  13. arg = arg.lower()
  14. if arg in ('yes', 'on', '1', 'true', 1):
  15. return True
  16. return False
  17. def interactive_console():
  18. '''
  19. Return whether the current console is "interactive". Meaning: Capable of
  20. user input and not just something like a cron job.
  21. '''
  22. return sys.stderr.isatty() and os.environ.get('TERM') != 'dumb'
  23. def should_do_markup(no_color, configs):
  24. '''
  25. Given the value of the command-line no-color argument, and a dict of configuration filename to
  26. corresponding parsed configuration, determine if we should enable color marking up.
  27. '''
  28. if no_color:
  29. return False
  30. if any(config.get('color', True) is False for config in configs.values()):
  31. return False
  32. if os.environ.get('NO_COLOR', None):
  33. return False
  34. py_colors = os.environ.get('PY_COLORS', None)
  35. if py_colors is not None:
  36. return to_bool(py_colors)
  37. return interactive_console()
  38. class Multi_stream_handler(logging.Handler):
  39. '''
  40. A logging handler that dispatches each log record to one of multiple stream handlers depending
  41. on the record's log level.
  42. '''
  43. def __init__(self, log_level_to_stream_handler):
  44. super(Multi_stream_handler, self).__init__()
  45. self.log_level_to_handler = log_level_to_stream_handler
  46. self.handlers = set(self.log_level_to_handler.values())
  47. def flush(self): # pragma: no cover
  48. super(Multi_stream_handler, self).flush()
  49. for handler in self.handlers:
  50. handler.flush()
  51. def emit(self, record):
  52. '''
  53. Dispatch the log record to the appropriate stream handler for the record's log level.
  54. '''
  55. self.log_level_to_handler[record.levelno].emit(record)
  56. def setFormatter(self, formatter): # pragma: no cover
  57. super(Multi_stream_handler, self).setFormatter(formatter)
  58. for handler in self.handlers:
  59. handler.setFormatter(formatter)
  60. def setLevel(self, level): # pragma: no cover
  61. super(Multi_stream_handler, self).setLevel(level)
  62. for handler in self.handlers:
  63. handler.setLevel(level)
  64. class Log_prefix_formatter(logging.Formatter):
  65. def __init__(self, fmt='{prefix}{message}', style='{', *args, **kwargs): # pragma: no cover
  66. self.prefix = None
  67. super(Log_prefix_formatter, self).__init__(fmt=fmt, style=style, *args, **kwargs)
  68. def format(self, record): # pragma: no cover
  69. record.prefix = f'{self.prefix}: ' if self.prefix else ''
  70. return super(Log_prefix_formatter, self).format(record)
  71. class Color(enum.Enum):
  72. RESET = 0
  73. RED = 31
  74. GREEN = 32
  75. YELLOW = 33
  76. MAGENTA = 35
  77. CYAN = 36
  78. class Console_color_formatter(logging.Formatter):
  79. def __init__(self, *args, **kwargs):
  80. self.prefix = None
  81. super(Console_color_formatter, self).__init__(
  82. '{prefix}{message}', style='{', *args, **kwargs
  83. )
  84. def format(self, record):
  85. add_custom_log_levels()
  86. color = (
  87. {
  88. logging.CRITICAL: Color.RED,
  89. logging.ERROR: Color.RED,
  90. logging.WARN: Color.YELLOW,
  91. logging.ANSWER: Color.MAGENTA,
  92. logging.INFO: Color.GREEN,
  93. logging.DEBUG: Color.CYAN,
  94. }
  95. .get(record.levelno)
  96. .value
  97. )
  98. record.prefix = f'{self.prefix}: ' if self.prefix else ''
  99. return color_text(color, super(Console_color_formatter, self).format(record))
  100. def ansi_escape_code(color): # pragma: no cover
  101. '''
  102. Given a color value, produce the corresponding ANSI escape code.
  103. '''
  104. return f'\x1b[{color}m'
  105. def color_text(color, message):
  106. '''
  107. Given a color value and a message, return the message colored with ANSI escape codes.
  108. '''
  109. if not color:
  110. return message
  111. return f'{ansi_escape_code(color)}{message}{ansi_escape_code(Color.RESET.value)}'
  112. def add_logging_level(level_name, level_number):
  113. '''
  114. Globally add a custom logging level based on the given (all uppercase) level name and number.
  115. Do this idempotently.
  116. Inspired by https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945
  117. '''
  118. method_name = level_name.lower()
  119. if not hasattr(logging, level_name):
  120. logging.addLevelName(level_number, level_name)
  121. setattr(logging, level_name, level_number)
  122. if not hasattr(logging, method_name):
  123. def log_for_level(self, message, *args, **kwargs): # pragma: no cover
  124. if self.isEnabledFor(level_number):
  125. self._log(level_number, message, args, **kwargs)
  126. setattr(logging.getLoggerClass(), method_name, log_for_level)
  127. if not hasattr(logging.getLoggerClass(), method_name):
  128. def log_to_root(message, *args, **kwargs): # pragma: no cover
  129. logging.log(level_number, message, *args, **kwargs)
  130. setattr(logging, method_name, log_to_root)
  131. ANSWER = logging.WARN - 5
  132. DISABLED = logging.CRITICAL + 10
  133. def add_custom_log_levels(): # pragma: no cover
  134. '''
  135. Add a custom log level between WARN and INFO for user-requested answers.
  136. '''
  137. add_logging_level('ANSWER', ANSWER)
  138. add_logging_level('DISABLED', DISABLED)
  139. def get_log_prefix():
  140. '''
  141. Return the current log prefix set by set_log_prefix(). Return None if no such prefix exists.
  142. It would be a whole lot easier to use logger.Formatter(defaults=...) instead, but that argument
  143. doesn't exist until Python 3.10+.
  144. '''
  145. try:
  146. formatter = next(
  147. handler.formatter
  148. for handler in logging.getLogger().handlers
  149. if handler.formatter
  150. if hasattr(handler.formatter, 'prefix')
  151. )
  152. except StopIteration:
  153. return None
  154. return formatter.prefix
  155. def set_log_prefix(prefix):
  156. '''
  157. Given a log prefix as a string, set it into the each handler's formatter so that it can inject
  158. the prefix into each logged record.
  159. '''
  160. for handler in logging.getLogger().handlers:
  161. if handler.formatter and hasattr(handler.formatter, 'prefix'):
  162. handler.formatter.prefix = prefix
  163. class Log_prefix:
  164. '''
  165. A Python context manager for setting a log prefix so that it shows up in every subsequent
  166. logging message for the duration of the context manager. For this to work, it relies on each
  167. logging formatter to be initialized with "{prefix}" somewhere in its logging format.
  168. Example use as a context manager:
  169. with borgmatic.logger.Log_prefix('myprefix'):
  170. do_something_that_logs()
  171. For the scope of that "with" statement, any logs created are prefixed with "myprefix: ".
  172. Afterwards, the prefix gets restored to whatever it was prior to the context manager.
  173. '''
  174. def __init__(self, prefix):
  175. '''
  176. Given the desired log prefix, save it for use below. Set prefix to None to disable any
  177. prefix from getting logged.
  178. '''
  179. self.prefix = prefix
  180. self.original_prefix = None
  181. def __enter__(self):
  182. '''
  183. Set the prefix onto the formatter defaults for every logging handler so that the prefix ends
  184. up in every log message. But first, save off any original prefix so that it can be restored
  185. below.
  186. '''
  187. self.original_prefix = get_log_prefix()
  188. set_log_prefix(self.prefix)
  189. def __exit__(self, exception_type, exception, traceback):
  190. '''
  191. Restore any original prefix.
  192. '''
  193. set_log_prefix(self.original_prefix)
  194. class Delayed_logging_handler(logging.handlers.BufferingHandler):
  195. '''
  196. A logging handler that buffers logs and doesn't flush them until explicitly flushed (after
  197. target handlers are actually set). It's useful for holding onto messages logged before logging
  198. is configured, ensuring those records eventually make their way to the relevant logging
  199. handlers.
  200. When flushing, don't forward log records to a target handler if the record's log level is below
  201. that of the handler. This recreates the standard logging behavior of, say, logging.DEBUG records
  202. getting suppressed if a handler's level is only set to logging.INFO.
  203. '''
  204. def __init__(self):
  205. super(Delayed_logging_handler, self).__init__(capacity=0)
  206. self.targets = None
  207. def shouldFlush(self, record):
  208. return self.targets is not None
  209. def flush(self):
  210. self.acquire()
  211. try:
  212. if not self.targets:
  213. return
  214. for record in self.buffer:
  215. for target in self.targets:
  216. if record.levelno >= target.level:
  217. target.handle(record)
  218. self.buffer.clear()
  219. finally:
  220. self.release()
  221. def configure_delayed_logging(): # pragma: no cover
  222. '''
  223. Configure a delayed logging handler to buffer anything that gets logged until we're ready to
  224. deal with it.
  225. '''
  226. logging.basicConfig(
  227. level=logging.DEBUG,
  228. handlers=[Delayed_logging_handler()],
  229. )
  230. def flush_delayed_logging(target_handlers):
  231. '''
  232. Flush any previously buffered logs to our "real" logging handlers.
  233. '''
  234. root_logger = logging.getLogger()
  235. if root_logger.handlers and isinstance(root_logger.handlers[0], Delayed_logging_handler):
  236. delayed_handler = root_logger.handlers[0]
  237. delayed_handler.targets = target_handlers
  238. delayed_handler.flush()
  239. root_logger.removeHandler(delayed_handler)
  240. def configure_logging(
  241. console_log_level,
  242. syslog_log_level=None,
  243. log_file_log_level=None,
  244. monitoring_log_level=None,
  245. log_file=None,
  246. log_file_format=None,
  247. color_enabled=True,
  248. ):
  249. '''
  250. Configure logging to go to both the console and (syslog or log file). Use the given log levels,
  251. respectively. If color is enabled, set up log formatting accordingly.
  252. Raise FileNotFoundError or PermissionError if the log file could not be opened for writing.
  253. '''
  254. add_custom_log_levels()
  255. if syslog_log_level is None:
  256. syslog_log_level = logging.DISABLED
  257. if log_file_log_level is None:
  258. log_file_log_level = console_log_level
  259. if monitoring_log_level is None:
  260. monitoring_log_level = console_log_level
  261. # Log certain log levels to console stderr and others to stdout. This supports use cases like
  262. # grepping (non-error) output.
  263. console_disabled = logging.NullHandler()
  264. console_error_handler = logging.StreamHandler(sys.stderr)
  265. console_standard_handler = logging.StreamHandler(sys.stdout)
  266. console_handler = Multi_stream_handler(
  267. {
  268. logging.DISABLED: console_disabled,
  269. logging.CRITICAL: console_error_handler,
  270. logging.ERROR: console_error_handler,
  271. logging.WARN: console_error_handler,
  272. logging.ANSWER: console_standard_handler,
  273. logging.INFO: console_standard_handler,
  274. logging.DEBUG: console_standard_handler,
  275. }
  276. )
  277. if color_enabled:
  278. console_handler.setFormatter(Console_color_formatter())
  279. else:
  280. console_handler.setFormatter(Log_prefix_formatter())
  281. console_handler.setLevel(console_log_level)
  282. handlers = [console_handler]
  283. if syslog_log_level != logging.DISABLED:
  284. syslog_path = None
  285. if os.path.exists('/dev/log'):
  286. syslog_path = '/dev/log'
  287. elif os.path.exists('/var/run/syslog'):
  288. syslog_path = '/var/run/syslog'
  289. elif os.path.exists('/var/run/log'):
  290. syslog_path = '/var/run/log'
  291. if syslog_path:
  292. syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
  293. syslog_handler.setFormatter(
  294. Log_prefix_formatter(
  295. 'borgmatic: {levelname} {prefix}{message}', # noqa: FS003
  296. )
  297. )
  298. syslog_handler.setLevel(syslog_log_level)
  299. handlers.append(syslog_handler)
  300. if log_file and log_file_log_level != logging.DISABLED:
  301. file_handler = logging.handlers.WatchedFileHandler(log_file)
  302. file_handler.setFormatter(
  303. Log_prefix_formatter(
  304. log_file_format or '[{asctime}] {levelname}: {prefix}{message}', # noqa: FS003
  305. )
  306. )
  307. file_handler.setLevel(log_file_log_level)
  308. handlers.append(file_handler)
  309. flush_delayed_logging(handlers)
  310. logging.basicConfig(
  311. level=min(handler.level for handler in handlers),
  312. handlers=handlers,
  313. )