13
\$\begingroup\$

Due to several limitations of the standard library's logging module, I wrote my own.
Below are the most important parts. You can find the whole library here.
Any feedback is welcome.

# fancylog - A library for human readable logging.
#
# Copyright (C) 2017 HOMEINFO - Digitale Informationssysteme GmbH
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""A library for beautiful, readble logging."""
from datetime import datetime
from enum import Enum
from sys import stdout, stderr
from threading import Thread
from time import sleep
from traceback import format_exc
from blessings import Terminal
__all__ = [
 'logging',
 'LogLevel',
 'LogEntry',
 'Logger',
 'TTYAnimation',
 'LoggingClass']
TERMINAL = Terminal()
def logging(name=None, level=None, parent=None, file=None):
 """Decorator to attach a logger to the respective class."""
 def wrap(obj):
 """Attaches the logger to the respective class."""
 logger_name = obj.__name__ if name is None else name
 obj.logger = Logger(logger_name, level=level, parent=parent, file=file)
 return obj
 return wrap
class LogLevel(Enum):
 """Logging levels."""
 DEBUG = (10, '🔧', TERMINAL.bold)
 SUCCESS = (20, '✓', TERMINAL.green)
 INFO = (30, 'i', TERMINAL.blue)
 WARNING = (70, '⚠', TERMINAL.yellow)
 ERROR = (80, '✗', TERMINAL.red)
 CRITICAL = (90, '☢', lambda string: TERMINAL.bold(TERMINAL.red(string)))
 FAILURE = (100, '☠', lambda string: TERMINAL.bold(TERMINAL.magenta(
 string)))
 def __init__(self, ident, symbol, format_):
 """Sets the identifier, symbol and color."""
 self.ident = ident
 self.symbol = symbol
 self.format = format_
 def __int__(self):
 """Returns the identifier."""
 return self.ident
 def __str__(self):
 """Returns the colored symbol."""
 return self.format(self.symbol)
 def __eq__(self, other):
 try:
 return int(self) == int(other)
 except TypeError:
 return NotImplemented
 def __gt__(self, other):
 try:
 return int(self) > int(other)
 except TypeError:
 return NotImplemented
 def __ge__(self, other):
 return self > other or self == other
 def __lt__(self, other):
 try:
 return int(self) < int(other)
 except TypeError:
 return NotImplemented
 def __le__(self, other):
 return self < other or self == other
 def __hash__(self):
 return hash((self.__class__, self.ident))
 @property
 def erroneous(self):
 """A log level is considered erroneous if it's identifier > 50."""
 return self.ident > 50
class LogEntry(Exception):
 """A log entry."""
 def __init__(self, *messages, level=LogLevel.ERROR, sep=None, color=None):
 """Sets the log level and the respective message(s)."""
 super().__init__()
 self.messages = messages
 self.level = level
 self.sep = ' ' if sep is None else sep
 self.color = color
 self.timestamp = datetime.now()
 def __hash__(self):
 """Returns a unique hash."""
 return hash(self._hash_tuple)
 @property
 def _hash_tuple(self):
 """Returns the tuple from which to create a unique hash."""
 return (self.__class__, self.level, self.messages, self.timestamp)
 @property
 def message(self):
 """Returns the message elements joint by the selected separator."""
 return self.sep.join(str(message) for message in self.messages)
 @property
 def text(self):
 """Returns the formatted message text."""
 if self.color is not None:
 return self.color(self.message)
 return self.message
class Logger:
 """A logger that can be nested."""
 CHILD_SEP = '→'
 def __init__(self, name, level=None, parent=None, file=None):
 """Sets the logger's name, log level, parent logger,
 log entry template and file.
 """
 self.name = name
 self.level = level or LogLevel.INFO
 self.parent = parent
 self.file = file
 self.template = '{1.level} {0}: {1.text}'
 def __str__(self):
 """Returns the logger's nested path as a string."""
 return self.CHILD_SEP.join(logger.name for logger in self.path)
 def __hash__(self):
 """Returns a unique hash."""
 return hash(self.__class__, self.name)
 def __enter__(self):
 """Returns itself."""
 return self
 def __exit__(self, _, value, __):
 """Logs risen log entries."""
 if isinstance(value, LogEntry):
 self.log_entry(value)
 return True
 return None
 @property
 def root(self):
 """Determines whether the logger is at the root level."""
 return self.parent is None
 @property
 def path(self):
 """Yields the logger's path."""
 if not self.root:
 yield from self.parent.path
 yield self
 @property
 def layer(self):
 """Returns the layer of the logger."""
 return 0 if self.root else self.parent.layer + 1
 def log_entry(self, log_entry):
 """Logs a log entry."""
 if log_entry.level >= self.level:
 if self.file is None:
 file = stderr if log_entry.level.erroneous else stdout
 else:
 file = self.file
 print(self.template.format(self, log_entry), file=file, flush=True)
 return log_entry
 def inherit(self, name, level=None, file=None):
 """Returns a new child of this logger."""
 level = self.level if level is None else level
 file = self.file if file is None else file
 return self.__class__(name, level=level, parent=self, file=file)
 def log(self, level, *messages, sep=None, color=None):
 """Logs messages of a certain log level."""
 log_entry = LogEntry(*messages, level=level, sep=sep, color=color)
 return self.log_entry(log_entry)
 def debug(self, *messages, sep=None, color=None):
 """Logs debug messages, defaulting to a stack trace."""
 if not messages:
 messages = ('Stacktrace:', format_exc())
 if sep is None:
 sep = '\n'
 return self.log(LogLevel.DEBUG, *messages, sep=sep, color=color)
 def success(self, *messages, sep=None, color=None):
 """Logs success messages."""
 return self.log(LogLevel.SUCCESS, *messages, sep=sep, color=color)
 def info(self, *messages, sep=None, color=None):
 """Logs info messages."""
 return self.log(LogLevel.INFO, *messages, sep=sep, color=color)
 def warning(self, *messages, sep=None, color=None):
 """Logs warning messages."""
 return self.log(LogLevel.WARNING, *messages, sep=sep, color=color)
 def error(self, *messages, sep=None, color=None):
 """Logs error messages."""
 return self.log(LogLevel.ERROR, *messages, sep=sep, color=color)
 def critical(self, *messages, sep=None, color=None):
 """Logs critical messages."""
 return self.log(LogLevel.CRITICAL, *messages, sep=sep, color=color)
 def failure(self, *messages, sep=None, color=None):
 """Logs failure messages."""
 return self.log(LogLevel.FAILURE, *messages, sep=sep, color=color)

Use example:

#! /usr/bin/env python3
"""divide.
Usage:
 divide <dividend> <divisor> [options]
Options:
 --help, -h Show this page.
""" 
from docopt import docopt
from fancylog import LogLevel, LogEntry, Logger
def divide(dividend, divisor):
 try:
 return dividend / divisor
 except ZeroDivisionError:
 raise LogEntry('Cannot divide by zero.', level=LogLevel.ERROR)
def main(options):
 with Logger('Division logger', level=LogLevel.SUCCESS) as logger:
 try:
 dividend = float(options['<dividend>'])
 except ValueError:
 logger.error('Divident is not a float.')
 return
 try:
 divisor = float(options['<divisor>'])
 except ValueError:
 logger.error('Divisor is not a float.')
 return
 logger.success(divide(dividend, divisor))
if __name__ == '__main__':
 main(docopt(__doc__))
asked Feb 16, 2018 at 12:30
\$\endgroup\$
6
  • 1
    \$\begingroup\$ "Due to several limitations of the standard library's logging module" Which ones in particular are you trying to fix? \$\endgroup\$ Commented Feb 16, 2018 at 12:57
  • 2
    \$\begingroup\$ @Mast Comprehensive API, PEP8 compliant code, context management capability, easy formatting configuration, object-orientation... \$\endgroup\$ Commented Feb 16, 2018 at 13:10
  • \$\begingroup\$ Why self.level = level or LogLevel.INFO when every other check of the sort is of the form self.level = LogLevel.INFO if level is None else level? \$\endgroup\$ Commented Feb 16, 2018 at 14:04
  • 1
    \$\begingroup\$ Why not use total_ordering? \$\endgroup\$ Commented Feb 16, 2018 at 14:40
  • 1
    \$\begingroup\$ @Dannnno Good point. I also already refactored the several logging methods using functools.partialmethod in upstream. \$\endgroup\$ Commented Feb 16, 2018 at 14:59

1 Answer 1

4
\$\begingroup\$

The code reads good, I only have a few nitpicks:

  • Use default values where applicable instead of None: it will ease usage of the API as they can be introspected;

    class LogEntry(Exception):
     """A log entry."""
     def __init__(self, *messages, level=LogLevel.ERROR, sep=' ', color=None):
    
    class Logger:
     """A logger that can be nested."""
     CHILD_SEP = '→'
     def __init__(self, name, level=LogLevel.INFO, parent=None, file=None):
    
  • Change value of a parameter instead of reasigning it through a ternary: this make it more explicit about what you’re trying to do;

    def inherit(self, name, level=None, file=None):
     """Returns a new child of this logger."""
     if level is None:
     level = self.level
     if file is None:
     file = self.file
     return self.__class__(name, level=level, parent=self, file=file)
    
  • Avoid return None, especially as last intruction: it’s just noise.

Now, this is neat code and all, but it feels... rather limited. The two main concerns I have about this module is that:

  1. it only logs to files (or similar);
  2. it gives veeery few control over the formatting of the log message.

I sometimes need to send logs to the system syslog daemon and I can do it using a handler provided by the Python's logging module, but if I want to do that using yours, I would have to wrap syslog and your module myself... Same goes for sending logs over TCP (to a listening logstash for instance).

Same goes for the formatting of the message. As far as I hate % formatting imposed by the logging module, it is at least possible to writte a Formatter that is format aware and pass large objects to the logger which will use the format string to traverse it and retrieve values that I need to log only if it actually logs something. Using your approach, I can either write a non-flexible __str__ method on each object I want to log so I can have desired information (which is not always possible nor wanted, if I want to log several different things in several different places) or split the format string (bad for readability) and traverse the object to retrieve attributes myself which is a bit of overhead (some may be expensive properties, even) if the log ends up discarded.

Other missing things includes the ability to configure a logger hierarchy though a file and to retrieve/create any logger by name in any module.

I also don't really understand the use of the contextmanager, especially the LogEntry exception. As far as I find it interesting to create a specific logger for a specific portion of the code, why would I want to use the LogEntry "exception":

  1. it swallows the actual error, making the calling code unaware of any problem: if your example code were to use the value returned from divide, it would have a hard time doing so as None would be returned;
  2. it changes the type of the exception making any attempt to catch real errors moot. Why not keep it to the simple

    except XXX:
     logger.xxx(...)
     return
    

    that you use in your main? (or even better: except XXX: logger.xxx(...); raise)

    Can't you instead provide a utility function like current_logger that uses this context manager to return the appropriate one (or create a root logger if none is in use)? Or something akin to decimal.localcontext where you provide module-level logging functions (debug, success, info...) that applies to the current logger and other utilities to select which logger is the current one.

answered Feb 19, 2018 at 9:37
\$\endgroup\$
0

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.