Logging is pretty important. It's the primary way to capture the performance of code after it has left the developer's controlled environment. This means that the more complex a program becomes, the greater the need for a robust and mature logging system.
Fortunately, Python has a logging service built into its core system libraries that is both powerful and flexible -- so much so that it's slightly intimidating. This post will introduce the logging system through a collection of basic logging topics.
Replacing print
The most basic way of logging is to write messages to the console using the print
function.
print('This is an info message')
The print
function writes text to the system's standard output stream. For small scraps of code this works well because they are small scraps of code. Scaling this practice up to more complex projects, like applications or system libraries, becomes problematic. In those situations, where a single client is brining in various libraries from multiple developers, all of the print
s would overwhelm the user with information and turn the system console's into a performance bottleneck.
Library developers could work around this, maybe creating a dev and release mode of their library, but that's adding an extra layer of needless work and responsibility onto each individual library. Python's logging system has already solved this problem and splits the responsibility between the developer and client. The developer write the logging messages and the client controls the output.
Using Logger Objects
The developer-facing part of the logging system is the logger objects. These logger objects are aptly named logger
s and developers request one from the logging
module.
import logging
logger = logging.getLogger(__name__)
logger.info('This is an info message')
There are a few things to note with the snippet:
-
All logging calls must be done through the
logging
library.The logging library is a builtin system library and is quite mature, having been with Python standard library from the start.
-
Developers request logging objects from the logging system's
getLogger
function with a name, preferable using the__name__
value.The logger object is created on demand and managed by the logging system. The name parameter is simply a label to refer to the logger object later on. The name could be any pieces of text, but using the built-in
__name__
variable is a good habit to use from the start -- especially as you start developing more complex applications.The
__name__
variable is available in all files and contains the fully qualified module name. This means that developers get a working naming scheme for free with the linelogger = logging.getLogger(__name__)
copied and pasted throughout your project. -
Developers send messages through one of the logger's convenience functions.
While the logger's functions look like it is sending simple strings, each call makes a logging record that contains the text line as an attribute. The logging system also attaches a severity or logging levels to each record. The equivalent level of typical
print
messages is theinfo
level and each logger object has a convenience function to send messages on that level.The logging record are the internal objects that represent messages and there are additional tools and utilities to work with them, but that's beyond the scope of this article.
Initializing with basicConfig
Printing messages to the console with print
works right out of the box. Sending logging messages to the console, like in the above snippet, does not.
The logging system must be initialized and configured to send logging messages to the console. This configuration step will be custom for larger projects that require more than one output for messages, but smaller projects can initialize the system in a single line using the logging.basicConfig
function.
The basicConfig
function allows the user to configure the most frequently used options. The snippet from the example.py
module below initializes the logging system with a opinionated configuration that works for basic usage.
import logging
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info('This is an info message')
The function takes in multiple parameters, but the bare minimum should include the format
and the level
arguments.
The format
text is the template to apply to all messages and in our case we have a simple one that makes a logging message from three logging message properties:
asctime
: the time the message was createdlevelname
: the human readable name of the importance levelmessage
: the message itself
Running the code example above yields the print message, but also the time and the level name as well:
$ python example.py
2021-02-08 20:02:30,998 : INFO : This is an info message
The level
argument is a filter. Changing the value from logging.INFO
to a higher level, like logging.WARNING
will blocked the info messages from being displayed. Similarly, going to a lower level, like logging.DEBUG
, will allow the info
messages, but also include all debug
messages. But again, with the goal of minimally replacing the print statement, this can be set to logging.INFO
.
How to Initialize
Initialization should occur only once in an application's lifespan as each initialization destroyers the previous initialization. For this reason, initialization should not be done in a library but instead by done in the application's __main__
block.
The official __main__
documentation does a great job explaining what this block is. But, it's best to use the __main__
block to initialize the logging system because it is the main entry point of the application and executed before anything else. For example, update the above example.py
by adding a __main__
block:
import sys
import logging
def main(args=[]):
logger = logging.getLogger(__name__)
logger.info('This is an info message')
return 0
if __name__ == '__main__':
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
sys.exit(main(sys.argv))
If we execute example.py
directly, we see the logging statement.
$ python example.py
2021-02-08 20:03:01,900 : INFO : This is an info message
However, if we import
the module, the __main__
block (and the initialization) is ignored and the logging statement is skipped:
$ python -c "from example import main;main([])"
$
The second example doesn't have any logging messages because the __name__
value contains the module name example
instead of the __main__
value.
A note about logging's name
and __name__
Remember that using __name__
in logger = logging.getLogger(__name__)
is considered a best practice because it leverages the python module name stored in that variable and simplifies the way to acquire the logger through the code.
However, when using __name__
in the __main__
block, the value of __name__
changes to __main__
. If we include the %(name)s
field in our initialization we can see this in practice.
import sys
import logging
def main(args=[]):
logger = logging.getLogger(__name__)
logger.info('This is an info message')
return 0
if __name__ == '__main__':
logging.basicConfig(format='%(asctime)s : %(name)s %(levelname)s : %(message)s', level=logging.INFO)
sys.exit(main(sys.argv))
Executing the file from the command line includes the __main__
in the logging message.
$ python example.py
2021-02-08 20:05:02,700 : __main__ - INFO : This is an info message
However, executing the code as a module (after we manually initialize the logging system), displays the name of the module example
in the name value:
$ python
>>> import logging
>>> logging.basicConfig(format='%(asctime)s : %(name)s %(levelname)s : %(message)s', level=logging.INFO)
>>> from example import main
>>> main([])
2021-02-08 20:07:03,600 : example - INFO : This is an info message
0
This is important to know as the client could very well use the name
field to filter in or out logging messages from a particular module.
Using different logging levels
The logging system attaches a numerical severity level to each logging message. These levels are actually customizable, but in practically all cases its best to just use the default levels define in the logging
module: debug
, info
, warning
, error
, and critical
.
The logger object implements a convenience logging function for each logging level, wrapping around the actual log
function. The log
function is public to use, and can be used for special handling or for custom levels:
import sys
import logging
def main(args=[]):
logger = logging.getLogger(__name__)
logger.log(5, 'This is a custom message at level 5')
logger.debug('This is a debug message')
logger.info('This is an info message')
logger.warning('This is a warning message')
logger.error('This is an error message')
logger.critical('This is a critical message')
logger.log(100, 'This is a custom message at level 100')
return 0
if __name__ == '__main__':
logging.basicConfig(format='%(asctime)s : %(name)s %(levelname)s : %(message)s', level=logging.INFO)
sys.exit(main(sys.argv))
The uses all convenience functions and uses custom levels 5
and 100
. Execute the above module yields the following results:
$ python example.py
2021-02-08 20:19:58,867 : __main__ INFO : This is an info message
2021-02-08 20:19:58,867 : __main__ WARNING : This is a warning message
2021-02-08 20:19:58,867 : __main__ ERROR : This is an error message
2021-02-08 20:19:58,867 : __main__ CRITICAL : This is a critical message
2021-02-08 20:19:58,867 : __main__ Level 100 : This is a custom message at level 100
All of the logging messages appear -- except for the 5
and debug
messages. This is because we set the global level parameter to logging.INFO
in our basicConfig
call. That level
parameter configures the system to filter out all messages with a severity level less than given level. Changing the value to logging.ERROR
will filter out 5
, debug
warning
, and info
messages:
import sys
import logging
def main(args=[]):
....
return 0
if __name__ == '__main__':
logging.basicConfig(format='%(asctime)s : %(name)s %(levelname)s : %(message)s', level=logging.ERROR)
sys.exit(main(sys.argv))
Executing the above module yields the following results:
$ python example.py
2021-02-08 20:36:05,829 : __main__ ERROR : This is an error message
2021-02-08 20:36:05,829 : __main__ CRITICAL : This is an critical message
2021-02-08 20:36:05,829 : __main__ Level 100 : This is an custom message at level 100
And likewise, setting the value to logging.NOTSET
is the equivalent of setting the filter to 0
and letting all messages through:
import sys
import logging
def main(args=[]):
....
return 0
if __name__ == '__main__':
logging.basicConfig(format='%(asctime)s : %(name)s %(levelname)s : %(message)s', level=logging.NOTSET)
sys.exit(main(sys.argv))
$ python example.py
2021-02-08 20:37:34,355 : __main__ Level 5 : This is an custom message at level 5
2021-02-08 20:37:34,355 : __main__ DEBUG : This is an debug message
2021-02-08 20:37:34,355 : __main__ INFO : This is an info message
2021-02-08 20:37:34,355 : __main__ WARNING : This is an warning message
2021-02-08 20:37:34,355 : __main__ ERROR : This is an error message
2021-02-08 20:37:34,355 : __main__ CRITICAL : This is an critical message
2021-02-08 20:37:34,355 : __main__ Level 100 : This is an custom message at level 100