Commit d3624f1d759ff59bda0e73a6f43da6ceb26f4410

Authored by Samir Aguiar
Committed by Christian Herdtweck
1 parent 660ea766

log_helper: fallback to root logger

The log_helper was refactored to fallback to the root logger when
logging is enabled. Now, instead of each logger having its own
level and handler, we reuse the level of the root logger by
setting the level of the loggers to logging.NOTSET. We also keep
the default propagation behavior so instead of having a custom
handler for each logger, they will keep the NullHandler and when
logging they will use the handler of the root logger. This also
allowed us to set the JSON formatter only for the root logger,
since it's the only one actually logging things.

The base class for loggers was also removed because the way it
was used it would replace the base class for every logger, which
would affect modules importing oletools files. We now use an
adapter that is only valid for loggers created by the log_helper.

The hook for exceptions now wraps any hook previosly set instead
of replacing it altogether and this is only done when calling
modules as script.

Finally, the log_at_current_level() function was replaced by a
print_str() function defined in the adapter that will print
instead of logging when not using JSON so we can keep the old
behavior.
oletools/util/log_helper/_json_formatter.py
... ... @@ -10,18 +10,15 @@ class JsonFormatter(logging.Formatter):
10 10  
11 11 def format(self, record):
12 12 """
13   - We accept messages that are either dictionaries or not.
14   - When we have dictionaries we can just serialize it as JSON right away.
  13 + Since we don't buffer messages, we always prepend messages with a comma to make
  14 + the output JSON-compatible. The only exception is when printing the first line,
  15 + so we need to keep track of it.
15 16 """
16   - trailing_comma = ','
  17 + json_dict = dict(msg=record.msg, level=record.levelname)
  18 + formatted_message = ' ' + json.dumps(json_dict)
17 19  
18 20 if self._is_first_line:
19   - trailing_comma = ''
20 21 self._is_first_line = False
  22 + return formatted_message
21 23  
22   - json_dict = record.msg \
23   - if isinstance(record.msg, dict) \
24   - else dict(msg=record.msg)
25   - json_dict['level'] = record.levelname
26   -
27   - return trailing_comma + ' ' + json.dumps(json_dict)
  24 + return ', ' + formatted_message
... ...
oletools/util/log_helper/_logger_adapter.py 0 → 100644
  1 +import logging
  2 +from . import _root_logger_wrapper
  3 +
  4 +
  5 +class OletoolsLoggerAdapter(logging.LoggerAdapter):
  6 + """
  7 + Adapter class for all loggers returned by the logging module.
  8 + """
  9 + _json_enabled = None
  10 +
  11 + def print_str(self, message):
  12 + """
  13 + This function replaces normal print() calls so we can format them as JSON
  14 + when needed or just print them right away otherwise.
  15 + """
  16 + if self._json_enabled and self._json_enabled():
  17 + # Messages from this function should always be printed,
  18 + # so when using JSON we log using the same level that set
  19 + self.log(_root_logger_wrapper.level(), message)
  20 + else:
  21 + print(message)
  22 +
  23 + def set_json_enabled_function(self, json_enabled):
  24 + """
  25 + Set a function to be called to check whether JSON output is enabled.
  26 + """
  27 + self._json_enabled = json_enabled
  28 +
  29 + def level(self):
  30 + return self.logger.level
... ...
oletools/util/log_helper/_logger_class.py deleted
1   -import logging
2   -from . import _root_logger_wrapper
3   -
4   -
5   -class OletoolsLogger(logging.Logger):
6   - """
7   - Default class for all loggers returned by the logging module.
8   - """
9   - def __init__(self, name, level=logging.NOTSET):
10   - super(self.__class__, self).__init__(name, level)
11   -
12   - def log_at_current_level(self, message):
13   - """
14   - Logs the message using the current level.
15   - This is useful for messages that should always appear,
16   - such as banners.
17   - """
18   -
19   - level = _root_logger_wrapper.get_root_logger_level() \
20   - if _root_logger_wrapper.is_logging_initialized() \
21   - else self.level
22   -
23   - self.log(level, message)
oletools/util/log_helper/_root_logger_wrapper.py
... ... @@ -3,12 +3,22 @@ import logging
3 3  
4 4 def is_logging_initialized():
5 5 """
6   - We use the same strategy as the logging module
7   - when checking if the logging was initialized -
8   - look for handlers in the root logger
  6 + We use the same strategy as the logging module when checking if
  7 + the logging was initialized - look for handlers in the root logger
9 8 """
10 9 return len(logging.root.handlers) > 0
11 10  
12 11  
13   -def get_root_logger_level():
  12 +def set_formatter(fmt):
  13 + """
  14 + Set the formatter to be used by every handler of the root logger.
  15 + """
  16 + if not is_logging_initialized():
  17 + return
  18 +
  19 + for handler in logging.root.handlers:
  20 + handler.setFormatter(fmt)
  21 +
  22 +
  23 +def level():
14 24 return logging.root.level
... ...
oletools/util/log_helper/log_helper.py
... ... @@ -41,9 +41,8 @@ General logging helpers
41 41 # TODO:
42 42  
43 43  
44   -from __future__ import print_function
45 44 from ._json_formatter import JsonFormatter
46   -from ._logger_class import OletoolsLogger
  45 +from ._logger_adapter import OletoolsLoggerAdapter
47 46 from . import _root_logger_wrapper
48 47 import logging
49 48 import sys
... ... @@ -66,8 +65,6 @@ class LogHelper:
66 65 self._all_names = set() # set so we do not have duplicates
67 66 self._use_json = False
68 67 self._is_enabled = False
69   - self._json_formatter = JsonFormatter()
70   - logging.setLoggerClass(OletoolsLogger)
71 68  
72 69 def get_or_create_silent_logger(self, name=DEFAULT_LOGGER_NAME, level=logging.CRITICAL + 1):
73 70 """
... ... @@ -75,13 +72,23 @@ class LogHelper:
75 72 as the handler (to avoid printing to the console).
76 73 By default we also use a higher logging level so every message will
77 74 be ignored.
78   - This is useful when we don't want to print anything when the logger
79   - is not configured by the main application.
  75 + This will prevent oletools from logging unnecessarily when being imported
  76 + from external tools.
80 77 """
81 78 return self._get_or_create_logger(name, level, logging.NullHandler())
82 79  
83 80 def enable_logging(self, use_json, level, log_format=DEFAULT_MESSAGE_FORMAT, stream=None):
84   - """ called from main after parsing arguments """
  81 + """
  82 + This function initializes the root logger and enables logging.
  83 + We set the level of the root logger to the one passed by calling logging.basicConfig.
  84 + We also set the level of every logger we created to 0 (logging.NOTSET), meaning that
  85 + the level of the root logger will be used to tell if messages should be logged.
  86 + Additionally, since our loggers use the NullHandler, they won't log anything themselves,
  87 + but due to having propagation enabled they will pass messages to the root logger,
  88 + which in turn will log to the stream set in this function.
  89 + Since the root logger is the one doing the work, when using JSON we set its formatter
  90 + so that every message logged is JSON-compatible.
  91 + """
85 92 if self._is_enabled:
86 93 raise ValueError('re-enabling logging. Not sure whether that is ok...')
87 94  
... ... @@ -90,26 +97,23 @@ class LogHelper:
90 97 self._is_enabled = True
91 98  
92 99 self._use_json = use_json
93   -
94   - sys.excepthook = self._log_except_hook
  100 + sys.excepthook = self._get_except_hook(sys.excepthook)
95 101  
96 102 # since there could be loggers already created we go through all of them
97   - # and set their formatters to our custom Json formatter
98   - # also set their levels so they respect what the main module wants logged
  103 + # and set their levels to 0 so they will use the root logger's level
99 104 for name in self._all_names:
100 105 logger = self.get_or_create_silent_logger(name)
  106 + self._set_logger_level(logger, logging.NOTSET)
101 107  
102   - if self._use_json:
103   - self._make_json(logger)
104   -
105   - # print the start of the logging message list
  108 + # add a JSON formatter to the root logger, which will be used by every logger
106 109 if self._use_json:
  110 + _root_logger_wrapper.set_formatter(JsonFormatter())
107 111 print('[')
108 112  
109 113 def end_logging(self):
110 114 """
111   - Must be called at the end of the main function
112   - if the caller wants json-compatible ouptut
  115 + Must be called at the end of the main function if the caller wants
  116 + json-compatible output
113 117 """
114 118 if not self._is_enabled:
115 119 return
... ... @@ -124,60 +128,60 @@ class LogHelper:
124 128 print(']')
125 129 self._use_json = False
126 130  
127   - def _log_except_hook(self, exctype, value, traceback):
  131 + def _get_except_hook(self, old_hook):
128 132 """
129   - Global hook for exceptions so we can always end logging
  133 + Global hook for exceptions so we can always end logging.
  134 + We wrap any hook currently set to avoid overwriting global hooks set by oletools.
  135 + Note that this is only called by enable_logging, which in turn is called by
  136 + the main() function in oletools' scripts. When scripts are being imported this
  137 + code won't execute and won't affect global hooks.
130 138 """
131   - self.end_logging()
132   - sys.__excepthook__(exctype, value, traceback)
  139 + def hook(exctype, value, traceback):
  140 + self.end_logging()
  141 + old_hook(exctype, value, traceback)
  142 +
  143 + return hook
133 144  
134 145 def _get_or_create_logger(self, name, level, handler=None):
135 146 """
136   - If a logger doesn't exist, we create it and set the handler,
137   - if it given. This avoids messing with existing loggers.
138   - If we are using json then we also skip adding a handler,
139   - since it would be removed afterwards
  147 + Get or create a new logger. This newly created logger will have the
  148 + handler and level that was passed, but if it already exists it's not changed.
  149 + We also wrap the logger in an adapter so we can easily extend its functionality.
140 150 """
141 151  
142 152 # logging.getLogger creates a logger if it doesn't exist,
143 153 # so we need to check before calling it
144   - if handler and not self._use_json and not self._log_exists(name):
  154 + if handler and not self._log_exists(name):
145 155 logger = logging.getLogger(name)
146 156 logger.addHandler(handler)
  157 + self._set_logger_level(logger, level)
147 158 else:
148 159 logger = logging.getLogger(name)
149 160  
150   - self._set_logger_level(logger, level)
  161 + # Keep track of every logger we created so we can easily change
  162 + # their levels whenever needed
151 163 self._all_names.add(name)
152 164  
153   - if self._use_json:
154   - self._make_json(logger)
155   -
156   - return logger
157   -
158   - def _make_json(self, logger):
159   - """
160   - Replace handlers of every logger by a handler that uses the JSON formatter
161   - """
162   - for handler in logger.handlers:
163   - logger.removeHandler(handler)
164   - new_handler = logging.StreamHandler(sys.stdout)
165   - new_handler.setFormatter(self._json_formatter)
166   - logger.addHandler(new_handler)
  165 + adapted_logger = OletoolsLoggerAdapter(logger, None)
  166 + adapted_logger.set_json_enabled_function(lambda: self._use_json)
167 167  
168   - # Don't let it propagate to parent loggers, otherwise we might get
169   - # duplicated messages from the root logger
170   - logger.propagate = False
  168 + return adapted_logger
171 169  
172 170 @staticmethod
173 171 def _set_logger_level(logger, level):
174 172 """
175   - If the logging is already initialized, we use the same level that
176   - was set to the root logger. This prevents imported modules' loggers
177   - from messing with the main module logging.
  173 + If the logging is already initialized, we set the level of our logger
  174 + to 0, meaning that it will reuse the level of the root logger.
  175 + That means that if the root logger level changes, we will keep using
  176 + its level and not logging unnecessarily.
178 177 """
  178 +
  179 + # if this log was wrapped, unwrap it to set the level
  180 + if isinstance(logger, OletoolsLoggerAdapter):
  181 + logger = logger.logger
  182 +
179 183 if _root_logger_wrapper.is_logging_initialized():
180   - logger.setLevel(_root_logger_wrapper.get_root_logger_level())
  184 + logger.setLevel(logging.NOTSET)
181 185 else:
182 186 logger.setLevel(level)
183 187  
... ...