logger.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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 Console_no_color_formatter(logging.Formatter):
  65. def __init__(self, *args, **kwargs):
  66. super(Console_no_color_formatter, self).__init__('{prefix}{message}', style='{', defaults={'prefix': ''}, *args, **kwargs)
  67. class Color(enum.Enum):
  68. RESET = 0
  69. RED = 31
  70. GREEN = 32
  71. YELLOW = 33
  72. MAGENTA = 35
  73. CYAN = 36
  74. class Console_color_formatter(logging.Formatter):
  75. def __init__(self, *args, **kwargs):
  76. super(Console_color_formatter, self).__init__('{prefix}{message}', style='{', defaults={'prefix': ''}, *args, **kwargs)
  77. def format(self, record):
  78. add_custom_log_levels()
  79. color = (
  80. {
  81. logging.CRITICAL: Color.RED,
  82. logging.ERROR: Color.RED,
  83. logging.WARN: Color.YELLOW,
  84. logging.ANSWER: Color.MAGENTA,
  85. logging.INFO: Color.GREEN,
  86. logging.DEBUG: Color.CYAN,
  87. }
  88. .get(record.levelno)
  89. .value
  90. )
  91. return color_text(color, super(Console_color_formatter, self).format(record))
  92. def ansi_escape_code(color): # pragma: no cover
  93. '''
  94. Given a color value, produce the corresponding ANSI escape code.
  95. '''
  96. return f'\x1b[{color}m'
  97. def color_text(color, message):
  98. '''
  99. Given a color value and a message, return the message colored with ANSI escape codes.
  100. '''
  101. if not color:
  102. return message
  103. return f'{ansi_escape_code(color)}{message}{ansi_escape_code(Color.RESET.value)}'
  104. def add_logging_level(level_name, level_number):
  105. '''
  106. Globally add a custom logging level based on the given (all uppercase) level name and number.
  107. Do this idempotently.
  108. Inspired by https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945
  109. '''
  110. method_name = level_name.lower()
  111. if not hasattr(logging, level_name):
  112. logging.addLevelName(level_number, level_name)
  113. setattr(logging, level_name, level_number)
  114. if not hasattr(logging, method_name):
  115. def log_for_level(self, message, *args, **kwargs): # pragma: no cover
  116. if self.isEnabledFor(level_number):
  117. self._log(level_number, message, args, **kwargs)
  118. setattr(logging.getLoggerClass(), method_name, log_for_level)
  119. if not hasattr(logging.getLoggerClass(), method_name):
  120. def log_to_root(message, *args, **kwargs): # pragma: no cover
  121. logging.log(level_number, message, *args, **kwargs)
  122. setattr(logging, method_name, log_to_root)
  123. ANSWER = logging.WARN - 5
  124. DISABLED = logging.CRITICAL + 10
  125. def add_custom_log_levels(): # pragma: no cover
  126. '''
  127. Add a custom log level between WARN and INFO for user-requested answers.
  128. '''
  129. add_logging_level('ANSWER', ANSWER)
  130. add_logging_level('DISABLED', DISABLED)
  131. def set_log_prefix(prefix):
  132. '''
  133. Given a prefix string, set it onto the formatter defaults for every logging handler so that it
  134. shows up in every subsequent logging message. For this to work, this relies on each logging
  135. formatter to be initialized with "{prefix}" somewhere in its logging format.
  136. '''
  137. for handler in logging.getLogger().handlers:
  138. handler.formatter._style._defaults = {'prefix': f'{prefix}: ' if prefix else ''}
  139. def configure_logging(
  140. console_log_level,
  141. syslog_log_level=None,
  142. log_file_log_level=None,
  143. monitoring_log_level=None,
  144. log_file=None,
  145. log_file_format=None,
  146. color_enabled=True,
  147. ):
  148. '''
  149. Configure logging to go to both the console and (syslog or log file). Use the given log levels,
  150. respectively. If color is enabled, set up log formatting accordingly.
  151. Raise FileNotFoundError or PermissionError if the log file could not be opened for writing.
  152. '''
  153. add_custom_log_levels()
  154. if syslog_log_level is None:
  155. syslog_log_level = logging.DISABLED
  156. if log_file_log_level is None:
  157. log_file_log_level = console_log_level
  158. if monitoring_log_level is None:
  159. monitoring_log_level = console_log_level
  160. # Log certain log levels to console stderr and others to stdout. This supports use cases like
  161. # grepping (non-error) output.
  162. console_disabled = logging.NullHandler()
  163. console_error_handler = logging.StreamHandler(sys.stderr)
  164. console_standard_handler = logging.StreamHandler(sys.stdout)
  165. console_handler = Multi_stream_handler(
  166. {
  167. logging.DISABLED: console_disabled,
  168. logging.CRITICAL: console_error_handler,
  169. logging.ERROR: console_error_handler,
  170. logging.WARN: console_error_handler,
  171. logging.ANSWER: console_standard_handler,
  172. logging.INFO: console_standard_handler,
  173. logging.DEBUG: console_standard_handler,
  174. }
  175. )
  176. if color_enabled:
  177. console_handler.setFormatter(Console_color_formatter())
  178. else:
  179. console_handler.setFormatter(Console_no_color_formatter())
  180. console_handler.setLevel(console_log_level)
  181. handlers = [console_handler]
  182. if syslog_log_level != logging.DISABLED:
  183. syslog_path = None
  184. if os.path.exists('/dev/log'):
  185. syslog_path = '/dev/log'
  186. elif os.path.exists('/var/run/syslog'):
  187. syslog_path = '/var/run/syslog'
  188. elif os.path.exists('/var/run/log'):
  189. syslog_path = '/var/run/log'
  190. if syslog_path:
  191. syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
  192. syslog_handler.setFormatter(
  193. logging.Formatter('borgmatic: {levelname} {prefix}{message}', style='{', defaults={'prefix': ''}) # noqa: FS003
  194. )
  195. syslog_handler.setLevel(syslog_log_level)
  196. handlers.append(syslog_handler)
  197. if log_file and log_file_log_level != logging.DISABLED:
  198. file_handler = logging.handlers.WatchedFileHandler(log_file)
  199. file_handler.setFormatter(
  200. logging.Formatter(
  201. log_file_format or '[{asctime}] {levelname}: {prefix}{message}', style='{', defaults={'prefix': ''} # noqa: FS003
  202. )
  203. )
  204. file_handler.setLevel(log_file_log_level)
  205. handlers.append(file_handler)
  206. logging.basicConfig(
  207. level=min(handler.level for handler in handlers),
  208. handlers=handlers,
  209. )