e85db0bdee09e83502d78f66eb6c7b29bb18195e
[pyi2ncommon] / src / log_helpers.py
1 # The software in this package is distributed under the GNU General
2 # Public License version 2 (with a special exception described below).
3 #
4 # A copy of GNU General Public License (GPL) is included in this distribution,
5 # in the file COPYING.GPL.
6 #
7 # As a special exception, if other files instantiate templates or use macros
8 # or inline functions from this file, or you compile this file and link it
9 # with other works to produce a work based on this file, this file
10 # does not by itself cause the resulting work to be covered
11 # by the GNU General Public License.
12 #
13 # However the source code for this file must still be made available
14 # in accordance with section (3) of the GNU General Public License.
15 #
16 # This exception does not invalidate any other reasons why a work based
17 # on this file might be covered by the GNU General Public License.
18 #
19 # Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com>
20
21 """ Helpers for logging; featuring:
22
23 ShortLevelFormatter: provide a 4-char-sized field "shortlevel" for message
24 urgency (dbug/info/warn/err /crit)
25
26 I2nLogger: logger that provides a notice(), allows omission for str.format
27            and is quite convenient in general
28
29 get_logger: factor for creating I2nLoggers
30
31 Further ideas: ::
32 * allow milliseconds in dateformat field (see test_short_level_format)
33 * create own basicConfig-like function that uses our classes as default
34   --> started using I2nLogger constructor and get_logger
35 * try to find out module that calls I2nLogger constructor to provide a good
36   default value for name (finding out the current module is a pain in the a..)
37
38 .. todo:: create unittests from test_* functions at bottom
39 .. todo:: think about how to allow different levels per handler
40 .. todo:: do not limit logs by line numbers but by disc size? Warn when at 50%,
41          75%, 90%, 99% of limit?
42 """
43
44 import logging
45 from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL, NOTSET
46 from math import log10, floor
47 import sys
48
49 from .type_helpers import isstr
50
51 #: log level half-way between INFO and WARNING
52 NOTICE = (INFO + WARNING)//2
53 logging.addLevelName(NOTICE, 'NOTICE')
54
55 #: default formatting string for ShortLevelFormatter
56 DEFAULT_SHORT_LEVEL_FORMAT = '%(asctime)s:%(msecs)03d %(shortlevel)s| %(msg)s'
57
58 #: default formatting string for date/time in ShortLevelFormatter
59 DEFAULT_SHORT_LEVEL_DATE_FORMAT = '%H:%M:%S'
60
61 #: mapping from level name to level int for I2nLogger's set_level
62 LEVEL_DICT = dict(notset=NOTSET, debug=DEBUG, info=INFO, notice=NOTICE,
63                   warning=WARNING, error=ERROR, critical=CRITICAL)
64
65 #: min level allowed in I2nLogger
66 MIN_LEVEL = NOTSET
67
68 #: max level allowed in I2nLogger
69 MAX_LEVEL = CRITICAL
70
71 #: constant for I2nLogger streams argument: stream to stdout
72 STDOUT = sys.stdout
73
74
75 class ShortLevelFormatter(logging.Formatter):
76     """
77     Formatter for logging handlers that allows use of format field "shortlevel"
78
79     using this formatter, you can specify in the log message format string the
80     field "shortlevel" which will introduce in your log messages a 4-char field
81     for the log record urgency: "DEBUG" --> "dbug", "INFO" --> "info",
82     "WARNING" --> "warn", "ERROR" --> "err ", "CRITICAL" --> "crit"
83
84     All other functionality (like other format fields) is inherited from base
85     class Formatter
86
87     Most easily used indirectly through :py:class:`I2nLogger`
88     (uses this by default)
89
90     Explicit usage example::
91
92         logger = logging.getLogger(__name__)
93         logger.setLevel(logging.DEBUG)
94         handler = logging.StreamHandler()
95         handler.setLevel(logging.DEBUG)
96         formatter = ShortLevelFormatter('%(shortlevel)s| %(msg)s')
97         handler.setFormatter(formatter)
98         logger.addHandler(handler)
99
100     You can even add new levels::
101
102         notice = (logging.INFO + logging.WARNING)/2
103         formatter.add_level(notice, 'note')
104         logger.log(notice, 'more important than info but no warning nor error')
105
106     .. seealso:: testing funcion :py:func:`test_short_level_format`
107     """
108
109     def __init__(self, fmt=DEFAULT_SHORT_LEVEL_FORMAT,
110                  datefmt=DEFAULT_SHORT_LEVEL_DATE_FORMAT, *args, **kwargs):
111         """ creates a new ShortLevelFormatter
112
113         forwards all args to super class Formatter and initializes dict with
114         levelno and short string representations
115         """
116         self.parent = super(ShortLevelFormatter, self)
117         self.parent.__init__(fmt=fmt, datefmt=datefmt, *args, **kwargs)
118         self._shortlevel_dict = {DEBUG: 'dbug', INFO: 'info', WARNING: 'warn',
119                                  ERROR: 'err ', CRITICAL: 'crit',
120                                  NOTSET: '----'}
121
122     def format(self, record):
123         """ create string representation of given log record """
124         try:
125             record.shortlevel = self._shortlevel_dict[record.levelno]
126         except KeyError:
127             record.shortlevel = '????'
128
129         return self.parent.format(record)
130
131     def add_level(self, levelno, shortlevel_str):
132         """ add a new message urgency level
133
134         :param int levelno: numeric urgency level
135         :param str shortlevel_str: string representation of message urgency;
136                                    should be of length 4
137         :returns: nothing
138         """
139
140         self._shortlevel_dict[levelno] = shortlevel_str
141
142
143 #: memory for all I2nLogger instances in current session to avoid overwrite
144 #: used in :py:func:`get_logger` and :py:class:`I2nLogger` constructor
145 _i2n_loggers = {}
146
147
148 def get_logger(name, *args, **kwargs):
149     """ factory for :py:class:`I2nLogger`: create new or return existing object
150
151     all args as in :py:class:`I2nLogger` constructor; args will be ignored if
152     logger with given name exists already!
153     """
154
155     if name in _i2n_loggers:
156         return _i2n_loggers[name]    # ignore all args
157     else:
158         return I2nLogger(name, *args, **kwargs)
159
160
161 class I2nLogger:
162     """ a more convenient logger
163
164     Features::
165     * can be used without ".format()" as follows::
166
167         logger.info('the result is {0}', result)    # more convenient than...
168         logger.info('the result is {0}'.format(result))    # ... the original
169
170     * Has a :py:meth:`Logger.notice` function
171     * Has by default as only handler a :py:class:`logging.StreamHandler` to
172       stdout with a :py:class:`ShortLevelFormatter`
173     * Simplifies setting of logger's logging level using
174       :py:meth:`Logger.set_level`. The level is kept in sync between logger and
175       handlers and can be queried using :py:meth:`get_level`
176       (does not work accross logger hierarchies!)
177     * can specify name and level and [date]fmt all in constructor
178     * can limit the number of lines this logger will produce to prevent filling
179       hard drive with log file
180       (assumes one line per call to log/debug/info/notice/...,
181        only counts calls with priority above this logger's level)
182     * provides shorter method names: dbg, note, warn, err
183     * adds a NullHandler if `streams` and `files` and `handlers` are all `None`
184
185     ..note:: Creating multiple instances with the same name is not allowed and
186              will result in an error. Use :py:func:`get_logger` instead of this
187              constructor to avoid such situtations
188
189     ..note:: Do not change or use the underlying logger or functionality here
190              (in particular, get_level) is no longer reliable!
191     """
192
193     def __init__(self, name, level=INFO, fmt=DEFAULT_SHORT_LEVEL_FORMAT,
194                  datefmt=DEFAULT_SHORT_LEVEL_DATE_FORMAT,
195                  streams=STDOUT, files=None, handlers=None):
196         """ creates a I2nLogger; forwards args to logging.getLogger
197
198
199         :param str name: name of this logger, best practice is module name
200         :param int level: best use one of the constants DEBUG, INFO NOTICE,
201                           WARNING, ERROR
202         :param str fmt: format of log messages, see
203                         :py:class:`ShortLevelFormatter` for more help
204         :param str datefmt: format of date added to log messages, see
205                             :py:class:`ShortLevelFormatter` for more help
206         :param streams: list/tuple of or a single stream to log to, default is
207                         STDOUT (=sys.stdout)
208         :type streams: (:py:class:`io.IOBase`) or [:py:class:`io.IOBase`) or
209                        None
210         :param files: list/tuple or single file name to log to
211         :type files: None or (str) or [str]
212         :param handlers: other handlers to customize and use with this logger
213         :type handlers: None or [:py:class:`logging.Handler`] or
214                         (:py:class:`logging.Handler`)
215         :raise: ValueError if an I2nLogger with the same name exists already
216         """
217
218         # check if an instance with the same name has been created earlier
219         # to prevent conflicts
220         global _i2n_loggers
221         if name in _i2n_loggers:
222             raise ValueError("An I2nLogger with that exact name ('{0}') exists"
223                              " already -- use get_logger instead!"
224                              .format(name))
225
226         self._log = logging.getLogger(name)
227         if isinstance(level, str):
228             level = LEVEL_DICT[level]
229         self._level = min(MAX_LEVEL, max(MIN_LEVEL, level))
230         self._log.setLevel(self._level)
231
232         # remove handlers (sometimes there are mutliple by default)
233         for handler in self._log.handlers:
234             self._log.removeHandler(handler)
235
236         # create new handlers and formatter
237         if streams is None:
238             streams = []
239         elif not isinstance(streams, (list, tuple)):
240             streams = (streams, )
241         for stream in streams:
242             formatter = ShortLevelFormatter(fmt=fmt, datefmt=datefmt)
243             formatter.add_level(NOTICE, 'note')
244             new_handler = logging.StreamHandler(stream)
245             new_handler.setFormatter(formatter)
246             new_handler.setLevel(self._level)
247             self._log.addHandler(new_handler)
248
249         if files is None:
250             files = []
251         elif not isinstance(files, (list, tuple)):
252             files = (files, )
253         for file_name in files:
254             formatter = ShortLevelFormatter(fmt=fmt, datefmt=datefmt)
255             formatter.add_level(NOTICE, 'note')
256             new_handler = logging.FileHandler(file_name)
257             new_handler.setFormatter(formatter)
258             new_handler.setLevel(self._level)
259             self._log.addHandler(new_handler)
260
261         if handlers is None:
262             handlers = []
263         elif not isinstance(handlers, (list, tuple)):
264             handlers = (handlers, )
265         for handler in handlers:
266             formatter = ShortLevelFormatter(fmt=fmt, datefmt=datefmt)
267             formatter.add_level(NOTICE, 'note')
268             handler.setFormatter(formatter)
269             handler.setLevel(self._level)
270             self._log.addHandler(handler)
271
272         if not self._log.handlers:
273             self._log.addHandler(logging.NullHandler())
274
275         # remember that this logger is a I2nLogger
276         _i2n_loggers[name] = self
277
278     def dbg(self, message, *args, **kwargs):
279         self._log_no_test(DEBUG, message, *args, **kwargs)
280
281     def debug(self, message, *args, **kwargs):
282         self._log_no_test(DEBUG, message, *args, **kwargs)
283
284     def info(self, message, *args, **kwargs):
285         self._log_no_test(INFO, message, *args, **kwargs)
286
287     def note(self, message, *args, **kwargs):
288         self._log_no_test(NOTICE, message, *args, **kwargs)
289
290     def notice(self, message, *args, **kwargs):
291         self._log_no_test(NOTICE, message, *args, **kwargs)
292
293     def warn(self, message, *args, **kwargs):
294         self._log_no_test(WARNING, message, *args, **kwargs)
295
296     def warning(self, message, *args, **kwargs):
297         self._log_no_test(WARNING, message, *args, **kwargs)
298
299     def err(self, message, *args, **kwargs):
300         self._log_no_test(ERROR, message, *args, **kwargs)
301
302     def error(self, message, *args, **kwargs):
303         self._log_no_test(ERROR, message, *args, **kwargs)
304
305     def exception(self, message, *args, **kwargs):
306         if 'exc_info' in kwargs:
307             self._log_no_test(ERROR, message, *args, **kwargs)
308         else:
309             self._log_no_test(ERROR, message, *args, exc_info=True, **kwargs)
310
311     def critical(self, message, *args, **kwargs):
312         self._log_no_test(CRITICAL, message, *args, **kwargs)
313
314     def log(self, level, message, *args, **kwargs):
315         if isinstance(level, str):
316             level = LEVEL_DICT[level.lower()]
317         if level >= self._level:
318             try:
319                 message_formatted = message.format(*args)
320             except (KeyError, IndexError):
321                 message_formatted = '[LOG ARGS!] ' + message
322             self._log.log(level, message_formatted, **kwargs)
323
324     def _log_no_test(self, level, message, *args, **kwargs):
325         """ same as log() but without the isinstance test for internal use"""
326         if level >= self._level:
327             try:
328                 message_formatted = message.format(*args)
329             except (KeyError, IndexError):
330                 message_formatted = '[LOG ARGS!] ' + message
331             self._log.log(level, message_formatted, **kwargs)
332
333     def log_count_if_interesting(self, count, level=INFO, counter_name=None):
334         """ Log value of a counter in gradually coarser intervals
335
336         see :py:func:`is_interesting_count` for definition of "interesting"
337         """
338         if is_interesting_count(count):
339             if counter_name:
340                 self.log(level, '{0} counter is at {1}', counter_name, count)
341             else:
342                 self.log(level, 'Counter is at {0}', count)
343
344     def get_level(self):
345         """ return int level of this logger """
346         return self._level
347
348     def get_level_str(self):
349         """ returns :py:func:`logging.getLevelName` on :py:meth:`get_level` """
350         return logging.getLevelName(self._level)
351
352     def set_level(self, new_level):
353         """ set level given an int or a str
354
355         :arg new_level: int or str (str is converted to lower case)
356         :raises: KeyError if new_level is a string that is not in
357                  :py:data:`LEVEL_DICT`
358         """
359         if isstr(new_level):
360             self._level = LEVEL_DICT[new_level.lower()]
361         else:
362             self._level = min(MAX_LEVEL, max(MIN_LEVEL, new_level))
363         self._log.setLevel(self._level)
364         for handler in self._log.handlers:
365             handler.setLevel(self._level)
366
367
368 def n_digits(number):
369     """ returns the number of digits a number has in decimal format
370
371     :returns: 1 for 1...9, 2 for 10...99, 3 for 100...999, ...
372               0 for 0 (and everything else beween -1 and 1)
373               1 for -1...-9, 2 for -10...-99, ...
374     """
375     if abs(number) < 1:
376         return 0
377     else:
378         return floor(log10(abs(number)))+1
379
380 def is_interesting_count(counter):
381     """ return True if counter has reached an "interesting" value
382
383     For the counter to be "interesting" becomes ever harder. At first it is
384     easy (returns True for 1,2,3,6,10), then becomes harder (True only for
385     10,20,30,60,100) and harder (True for 100,200,300,600,1000) and this scheme
386     continues on a logartihmic scale.
387
388     An example that will print lots in the beginning and then less and less::
389
390         counter = 0
391         while not end_reached():
392             do_something()
393             if is_interesting_count(counter):
394                 log('reached iteration {0}'.format(counter))
395             counter += 1
396
397     Or implicitly using I2nLogger::log_count_if_interesting(counter)
398
399     :returns: True for a few values of counter, False most of the time
400     """
401
402     return float(counter) / 10.**(n_digits(counter)-1.) in (1., 2., 3., 6.)
403
404 def test_short_level_format():
405     """ quick test of :py:class:`ShortLevelFormatter` """
406
407     logger = logging.getLogger('logtest')
408     logger.setLevel(DEBUG)
409     handler = logging.StreamHandler()
410     handler.setLevel(DEBUG)
411     formatter = ShortLevelFormatter(
412         '%(asctime)s:%(msecs)03d %(shortlevel)s| %(msg)s'
413         '   [regular levelname=%(levelname)s]',
414         datefmt='%H:%M:%S')
415     handler.setFormatter(formatter)
416     logger.addHandler(handler)
417
418     # 'application' code
419     logger.debug('debug message')
420     logger.info('info message')
421     logger.warn('warn message')
422     logger.error('error message')
423     logger.critical('critical message')
424     logger.log(15, 'unknown level')
425     logger.log(NOTSET, 'level not set')
426
427     # add notice level
428     notice = (logging.INFO + logging.WARNING)/2
429     formatter.add_level(notice, 'note')
430     logger.log(notice, 'more important than info but no warning nor error')
431
432     # try if exception formatting still works:
433     try:
434         logger.info('this is what an exception looks like:')
435         impossible_result = 1/0
436         logger.critical('just divided 1/0! The result is {0}'
437                         .format(impossible_result))
438     except ZeroDivisionError:
439         logger.exception('1/0 still does not work!', exc_info=True)
440
441     # done
442     logger.info('done testing')
443
444 def test_get_logger():
445     log = get_logger('logger_test')
446     log2 = get_logger('logger_test')
447     print(log == log2)
448
449
450 def test_line_counter():
451     log = get_logger('logger_test', max_lines=10)
452     for idx in range(20):
453         for _ in range(20):
454             log.debug('should not show nor count')
455         print('calling log for idx {0}'.format(idx))
456         log.info('logging with idx {0}', idx)
457         log.log_count_if_interesting(idx)
458
459
460 def test_error_in_formatting():
461     log = get_logger('logger_test')
462     log.warn('forgot to add argument {} and {1} and {cnf} to format string')
463     log.warn('wrong number {} of arguments {}', 1)
464
465 if __name__ == '__main__':
466     #test_short_level_format()
467     #test_get_logger()
468     #test_line_counter()
469     test_error_in_formatting()