Logging

The Python logging module is one of those modules that are extremely useful, but it tends to be very difficult to use correctly. The result is often that people just disable logging completely and use print statements instead. This is insightful but a waste of the very extensive logging system in Python. If you've written Java code before, you might be familiar with the Log4j Java library. The Python logging module is largely and primarily based on that library.

The most important objects of the logging module are the following:

  • Logger: the actual logging interface
  • Handler: This processes the log statements and outputs them
  • Formatter: This formats the input data into a string
  • Filter: This allows filtering of certain messages

Within these objects, you can set the logging levels to one of the default levels:

  • CRITICAL: 50
  • ERROR: 40
  • WARNING: 30
  • INFO: 20
  • DEBUG: 10
  • NOTSET: 0

The numbers are the numeric values of these log levels. While you can generally ignore them, the order is obviously important while setting the minimum level. Also, when defining custom levels, you will have to overwrite existing levels if they have the same numeric value.

Configuration

There are several ways to configure the logging system, ranging from pure code to JSON files or even remote configuration. The examples will use parts of the logging module later discussed in this chapter, but the usage of the config system is all that matters here. If you are not interested in the internal workings of the logging module, you should be able to get by with just this paragraph of the logging section.

Basic logging configuration

The most basic logging configuration is, of course, no configuration, but that will not get you much useful output:

import logging

logging.debug('debug')
logging.info('info')
logging.warning('warning')
logging.error('error')
logging.critical('critical')

With the default log level, you will only see a warning and up:

# python log.py
WARNING:root:warning
ERROR:root:error
CRITICAL:root:critical

A quick and easy start for a configuration is basicConfig. I recommend using this if you just need some quick logging for a script you're writing, but not for a full-blown application. While you can configure pretty much anything you wish, once you get a more complicated setup, there are usually more convenient options. We will talk more about that in later paragraphs, but first, we have a basicConfig that configures our logger to display some more information, including the logger name:

import logging

log_format = (
    '[%(asctime)s] %(levelname)-8s %(name)-12s %(message)s')

logging.basicConfig(
    filename='debug.log',
    format=log_format,
    level=logging.DEBUG,
)

formatter = logging.Formatter(log_format)
handler = logging.StreamHandler()
handler.setLevel(logging.WARNING)
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)

We test the code:

logging.debug('debug')
logging.info('info')
some_logger = logging.getLogger('some')
some_logger.warning('warning')
some_logger.error('error')
other_logger = some_logger.getChild('other')
other_logger.critical('critical')

This will give us the following output on our screen:

# python log.py
[2015-12-02 15:56:19,449] WARNING  some         warning
[2015-12-02 15:56:19,449] ERROR    some         error
[2015-12-02 15:56:19,449] CRITICAL some.other   critical

And here is the output in the debug.log file:

[2015-12-02 15:56:19,449] DEBUG    root         debug
[2015-12-02 15:56:19,449] INFO     root         info
[2015-12-02 15:56:19,449] WARNING  some         warning
[2015-12-02 15:56:19,449] ERROR    some         error
[2015-12-02 15:56:19,449] CRITICAL some.other   critical

This configuration shows how log outputs can be configured with separate configurations, log levels, and, if you choose so, formatting. It tends to become unreadable though, which is why it's usually a better idea to use basicConfig only for simple configurations that don't involve multiple handlers.

Dictionary configuration

The dictconfig makes it possible to name all parts so that they can be reused easily, for example, a single formatter for multiple loggers and handlers. So let's rewrite our previous configuration using dictconfig:

from logging import config

config.dictConfig({
    'version': 1,
    'formatters': {
        'standard': {
            'format': '[%(asctime)s] %(levelname)-8s '
            '%(name)-12s %(message)s',
        },
    },
    'handlers': {
        'file': {
            'filename': 'debug.log',
            'level': 'DEBUG',
            'class': 'logging.FileHandler',
            'formatter': 'standard',
        },
        'stream': {
            'level': 'WARNING',
            'class': 'logging.StreamHandler',
            'formatter': 'standard',
        },
    },
    'loggers': {
        '': {
            'handlers': ['file', 'stream'],
            'level': 'DEBUG',
        },
    },
})

The nice thing about the dictionary configuration is that it's very easy to extend and/or overwrite the logging configuration. For example, if you want to change the formatter for all of your logging, you can simply change the standard formatter or even loop through handlers.

JSON configuration

Since dictconfig takes any type of dictionary, it is actually quite simple to implement a different type of reader employing JSON or YAML files. This is especially useful as they tend to be a bit friendlier towards non-Python programmers. As opposed to Python files, they are easily readable and writable from outside of Python.

Let's assume that we have a log_config.json file such as the following:

{
    "version": 1,
    "formatters": {
        "standard": {
            "format": "[%(asctime)s] %(levelname)-8s %(name)-12s %(message)s"
        }
    },
    "handlers": {
        "file": {
            "filename": "debug.log",
            "level": "DEBUG",
            "class": "logging.FileHandler",
            "formatter": "standard"
        },
        "stream": {
            "level": "WARNING",
            "class": "logging.StreamHandler",
            "formatter": "standard"
        }
    },
    "loggers": {
        "": {
            "handlers": ["file", "stream"],
            "level": "DEBUG"
        }
    }
}

We can simply use this code to read the config:

import json
from logging import config

with open('log_config.json') as fh:
    config.dictConfig(json.load(fh))

Ini file configuration

The file configuration is probably the most readable format for non-programmers. It uses the ini-style configuration format and uses the configparser module internally. The downside is that it is perhaps a little verbose, but it is clear enough and makes it easy to combine several configuration files without us having to worry too much about overwriting other configurations. Having said that, if dictConfig is an option, then it is most likely a better option. This is because fileConfig is slightly limited and awkward at times. Just look at the handlers as an example:

[formatters]
keys=standard

[handlers]
keys=file,stream

[loggers]
keys=root

[formatter_standard]
format=[%(asctime)s] %(levelname)-8s %(name)-12s %(message)s

[handler_file]
level=DEBUG
class=FileHandler
formatter=standard
args=('debug.log',)

[handler_stream]
level=WARNING
class=StreamHandler
formatter=standard
args=(sys.stderr,)

[logger_root]
handlers=file,stream
level=DEBUG

Reading the files is extremely easy though:

from logging import config

config.fileConfig('log_config.ini')

One thing to make note of, however, is that if you look carefully, you will see that this config is slightly different from the other configs. With fileConfig you can't just use keyword arguments alone. The args is required for both FileHandler and StreamHandler.

The network configuration

The network configuration is both very convenient and a bit dangerous, because it allows you to configure your logger on the fly while your application/script is still running. The dangerous part is that the config is (partially) read by using the eval function, which allows people to potentially execute code within your application remotely. Even though logging.config.listen only listens to local connections, it can still be dangerous if you execute the code on a shared/unsafe host.

Luckily, since version Python 3.4, it is possible to add a verify parameter, which is a function that will be executed to convert the input into the output. The default is obviously something along the lines of lambda config: config, but it can be configured to return just about anything.

To prove this point through an example, we need two scripts. One script will continuously print a few messages to the loggers and the other will change the logging configuration. We will start with the same test code that we had before but keep it running in an endless loop with a sleep in between:

import time
import logging
from logging import config

listener = config.listen()
listener.start()

try:
    while True:
        logging.debug('debug')
        logging.info('info')
        some_logger = logging.getLogger('some')
        some_logger.warning('warning')
        some_logger.error('error')
        other_logger = some_logger.getChild('other')
        other_logger.critical('critical')

        time.sleep(5)

except KeyboardInterrupt:
    # Stop listening and finish the listening thread
    logging.config.stopListening()
    listener.join()

Now comes the code that will send the configuration file:

import struct
import socket
from logging import config

with open('log_config.ini') as fh:
    data = fh.read()

# Open the socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Connect to the server
sock.connect(('127.0.0.1', config.DEFAULT_LOGGING_CONFIG_PORT))
# Send the magic logging packet
sock.send(struct.pack('>L', len(data)))
# Send the config
sock.send(data)
# And close the connection again
sock.close()

Next, let's see the output. After the first execution of the loop, we will execute the second script to read the logging configuration:

# python log_networkconfig.py
WARNING:some:warning
ERROR:some:error
CRITICAL:some.other:critical

You might be wondering where the rest of the output is. There is none. The debug.log file has been filled with messages like these, however:

[2015-12-03 12:32:38,894] DEBUG    root         debug
[2015-12-03 12:32:38,894] INFO     root         info

So what happened? This is where we see the pitfalls of custom loggers and configuration after using the loggers. The logging.config.listen function will modify the root logger as requested, but since the other loggers (some and some.other) weren't specified, they weren't modified. We modify the configuration to include them, as follows:

[formatters]
keys=standard

[handlers]
keys=file,stream

[loggers]
keys=root,some

[formatter_standard]
format=[%(asctime)s] %(levelname)-8s %(name)-12s %(message)s

[handler_file]
level=DEBUG
class=FileHandler
formatter=standard
args=('debug.log',)

[handler_stream]
level=WARNING
class=StreamHandler
formatter=standard
args=(sys.stderr,)

[logger_root]
handlers=file,stream
level=DEBUG

[logger_some]
level=DEBUG
qualname=some
handlers=

Now it works as expected:

# python log_networkconfig.py
WARNING:some:warning
ERROR:some:error
CRITICAL:some.other:critical
[2015-12-03 12:42:05,621] WARNING  some         warning
[2015-12-03 12:42:05,622] ERROR    some         error
[2015-12-03 12:42:05,622] CRITICAL some.other   critical

You will probably notice that we didn't add any handlers to the some logger. That's because the handler is already present—at the root level. However, without manually telling the logging module that the logger is there, it won't send it to the handler anymore. This is not problematic generally, but it's a dangerous pitfall when modifying logging configurations at runtime.

An alternative way to configure it without having this propagation issue is by disabling propagation altogether, but that will create an entirely new logger and will forget any configuration added to the root. So, if you have a handler for the error level at the root that gets sent to your error reporting system, it won't arrive anymore. In this case, however, the config is slightly clearer:

[logger_some]
handlers=file,stream
level=DEBUG
qualname=some
propagate=0

Logger

The main object that you will be using all the time with the logging module is the Logger object. This object contains all the APIs that you will need to do the actual logging. Most are simple enough but some require attention.

First of all, loggers inherit the parent settings by default. As we have seen previously with the propagate setting, by default, all settings will propagate from the parent. This is really useful when incorporating loggers within your files. Assuming your modules are using sane names and import paths, I recommend the following style of naming your loggers:

import logging


logger = logging.getLogger(__name__)


class Spam(object):
    def __init__(self, count):
        self.logger = logger.getChild(self.__class__.__name__)

By using this style, your loggers will get names such as main_module.sub_module.ClassName. Not only does this make your logs easier to read, but also it is easily possible to enable or disable logging per module with the propagation of log settings. To create a new log file that logs everything from main_module.sub_module, we can simply do this:

import logging

logger = logging.getLogger('main_module.sub_module')
logger.addHandler(logging.FileHandler('sub_module.log'))

Alternatively, you can configure it using your chosen configuration option, of course. The relevant point is that with sub-loggers, you have very fine-grained control over your loggers.

This includes increasing the log level:

import logging

logger = logging.getLogger('main_module.sub_module')
logger.setLevel(logging.DEBUG)

Usage

The usage of the Logger object is mostly identical to that of the bare logging module, but Logger actually supports a bit more. This is because the bare logging module just calls the functions on the root logger. It has a few very useful properties, although most of these are undocumented in the library:

  • Propagate: Whether to pass events to this logger or to the handlers of the parent loggers. Without this, a log message to main_module.sub_module won't be logged by main_module.

    The handle method will keep looking for parent handlers as long as those loggers have propagate set to true, which is the default.

  • Filters: These are the filters attached to the logger. They can be added through addFilter and removeFilter, To see whether a message will be filtered, the filter method can be used.
  • Disabled: By setting this property, it's possible to disable a certain logger. The regular API only allows disabling of all loggers below a certain level. This offers some fine-grained control.
  • Handlers: These are the handlers attached to the logger. They can be added through addHandler and removeHandler. The existence of any (inherited) handlers can be checked through the hasHandlers function.
  • Level: This is really an internal one as it simply has a numeric value and not a name. But beyond that, it doesn't take inheritance into account, so it's better to avoid the property and use the getEffectiveLevel function instead. To check whether the setting is enabled for a DEBUG for example, you can simply do logger.isEnabledFor(logging.DEBUG). Setting the property is possible through the setLevel function, of course.
  • Name: As this property's name says, it is very useful for your own reference, of course.

Now that you know about the properties, it is time to discuss the logging functions themselves. The functions you will use most often are the log, debug, info, warning, error, and critical log functions. They can be used quite simply, but they support string formatting as well, which is very useful:

import logging

logger = logging.getLogger()
exception = 'Oops...'
logger.error('Some horrible error: %r', exception)

You might wonder why we don't simply use the regular string formatting with % or string.format instead. The reason is that when parameters are used instead of preformatted strings, the handler gets them as parameters. The result is that you can group log messages by the original string, which is what tools such as sentry (https://github.com/getsentry/sentry) use.

There is more to it, however. In terms of parameters, *args are only for string formatting, but it's possible to add extra parameters to a log object using the extra keyword parameter:

import logging

logger = logging.getLogger()
logger.error('simple error', extra=dict(spam='some spam'))

These extra parameters can be used in the logging formatter to display extra information just like the standard formatting options:

import logging

logging.basicConfig(format='%(spam)s: %(message)s')
logger = logging.getLogger()
logger.error('the message', extra=dict(spam='some spam'))

This results in the following:

# python test_spam.py
some spam: the message

However, one of the most useful features is the support for exceptions:

import logging

logger = logging.getLogger()

try:
    raise RuntimeError('Not enough spam')
except:
    logger.exception('Got an exception')

logger.error('And an error')

This results in a stack trace for the exception, but it will not kill the code:

# python test_spam.py
Got an exception
Traceback (most recent call last):
  File "test_spam.py", line 6, in <module>
    raise RuntimeError('Not enough spam')
RuntimeError: Not enough spam
And an error
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset