Commit d3624f1d759ff59bda0e73a6f43da6ceb26f4410
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.
Showing
5 changed files
with
103 additions
and
85 deletions
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 | ... | ... |