Spec-Zone.ru › Python 3.11

Руководство по ведению журналов

Автор

Винай Саджип <vinay_sajip at red-dove dot com>

Эта страница содержит ряд рецептов, связанных с ведением журналов, которые оказались полезными в прошлом. Для ссылок на учебные материалы и справочную информацию см. Другие ресурсы.

Использование ведения журналов в нескольких модулях

Несколько вызовов logging.getLogger('someLogger') возвращают ссылку на один и тот же объект регистратора. Это справедливо не только в рамках одного модуля, но и между модулями, пока они находятся в одном процессе интерпретатора Python. Это верно для ссылок на один и тот же объект; кроме того, код приложения может определить и настроить родительский регистратор в одном модуле и создать (но не настроить) дочерний регистратор в отдельном модуле, и все вызовы регистратора дочернему модулю будут передаваться родителю. Вот модуль основной программы:

import logging
import auxiliary_module

# create logger with 'spam_application'
logger = logging.getLogger('spam_application')
logger.setLevel(logging.DEBUG)
# create file handler which logs even debug messages
fh = logging.FileHandler('spam.log')
fh.setLevel(logging.DEBUG)
# create console handler with a higher log level
ch = logging.StreamHandler()
ch.setLevel(logging.ERROR)
# create formatter and add it to the handlers
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
fh.setFormatter(formatter)
ch.setFormatter(formatter)
# add the handlers to the logger
logger.addHandler(fh)
logger.addHandler(ch)

logger.info('creating an instance of auxiliary_module.Auxiliary')
a = auxiliary_module.Auxiliary()
logger.info('created an instance of auxiliary_module.Auxiliary')
logger.info('calling auxiliary_module.Auxiliary.do_something')
a.do_something()
logger.info('finished auxiliary_module.Auxiliary.do_something')
logger.info('calling auxiliary_module.some_function()')
auxiliary_module.some_function()
logger.info('done with auxiliary_module.some_function()')

Вот вспомогательный модуль:

import logging

# create logger
module_logger = logging.getLogger('spam_application.auxiliary')

class Auxiliary:
    def __init__(self):
        self.logger = logging.getLogger('spam_application.auxiliary.Auxiliary')
        self.logger.info('creating an instance of Auxiliary')

    def do_something(self):
        self.logger.info('doing something')
        a = 1 + 1
        self.logger.info('done doing something')

def some_function():
    module_logger.info('received a call to "some_function"')

Вывод выглядит так:

2005-03-23 23:47:11,663 - spam_application - INFO -
   creating an instance of auxiliary_module.Auxiliary
2005-03-23 23:47:11,665 - spam_application.auxiliary.Auxiliary - INFO -
   creating an instance of Auxiliary
2005-03-23 23:47:11,665 - spam_application - INFO -
   created an instance of auxiliary_module.Auxiliary
2005-03-23 23:47:11,668 - spam_application - INFO -
   calling auxiliary_module.Auxiliary.do_something
2005-03-23 23:47:11,668 - spam_application.auxiliary.Auxiliary - INFO -
   doing something
2005-03-23 23:47:11,669 - spam_application.auxiliary.Auxiliary - INFO -
   done doing something
2005-03-23 23:47:11,670 - spam_application - INFO -
   finished auxiliary_module.Auxiliary.do_something
2005-03-23 23:47:11,671 - spam_application - INFO -
   calling auxiliary_module.some_function()
2005-03-23 23:47:11,672 - spam_application.auxiliary - INFO -
   received a call to 'some_function'
2005-03-23 23:47:11,673 - spam_application - INFO -
   done with auxiliary_module.some_function()

Ведение журналов из нескольких потоков

Ведение журналов из нескольких потоков не требует особых усилий. Следующий пример демонстрирует ведение журналов из основного (начального) потока и другого потока:

import logging
import threading
import time

def worker(arg):
    while not arg['stop']:
        logging.debug('Hi from myfunc')
        time.sleep(0.5)

def main():
    logging.basicConfig(level=logging.DEBUG, format='%(relativeCreated)6d %(threadName)s %(message)s')
    info = {'stop': False}
    thread = threading.Thread(target=worker, args=(info,))
    thread.start()
    while True:
        try:
            logging.debug('Hello from main')
            time.sleep(0.75)
        except KeyboardInterrupt:
            info['stop'] = True
            break
    thread.join()

if __name__ == '__main__':
    main()

При выполнении скрипта он должен вывести что-то вроде следующего:

   0 Thread-1 Hi from myfunc
   3 MainThread Hello from main
 505 Thread-1 Hi from myfunc
 755 MainThread Hello from main
1007 Thread-1 Hi from myfunc
1507 MainThread Hello from main
1508 Thread-1 Hi from myfunc
2010 Thread-1 Hi from myfunc
2258 MainThread Hello from main
2512 Thread-1 Hi from myfunc
3009 MainThread Hello from main
3013 Thread-1 Hi from myfunc
3515 Thread-1 Hi from myfunc
3761 MainThread Hello from main
4017 Thread-1 Hi from myfunc
4513 MainThread Hello from main
4518 Thread-1 Hi from myfunc

Это показывает вывод журнала, чередующийся, как можно было ожидать. Этот подход работает и для большего числа потоков, чем показано здесь.

Несколько обработчиков и форматеров

Регистраторы — это обычные объекты Python. Метод addHandler() не имеет минимального или максимального лимита для количества добавляемых обработчиков. Иногда для приложения будет полезно записывать все сообщения всех уровней в текстовый файл, одновременно регистрируя ошибки и вышеуказанные сообщения в консоли. Чтобы это настроить, просто настройте соответствующие обработчики. Вызовы ведения журнала в коде приложения останутся неизменными. Вот небольшая модификация предыдущего простого примера конфигурации, основанного на модулях:

import logging

logger = logging.getLogger('simple_example')
logger.setLevel(logging.DEBUG)
# create file handler which logs even debug messages
fh = logging.FileHandler('spam.log')
fh.setLevel(logging.DEBUG)
# create console handler with a higher log level
ch = logging.StreamHandler()
ch.setLevel(logging.ERROR)
# create formatter and add it to the handlers
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
fh.setFormatter(formatter)
# add the handlers to logger
logger.addHandler(ch)
logger.addHandler(fh)

# 'application' code
logger.debug('debug message')
logger.info('info message')
logger.warning('warn message')
logger.error('error message')
logger.critical('critical message')

Обратите внимание, что код «приложения» не заботится о нескольких обработчиках. Все, что изменилось, — это добавление и настройка нового обработчика с именем fh.

Возможность создавать новые обработчики с фильтрами более высокого или более низкого уровня серьезно помогает при написании и тестировании приложения. Вместо использования многих print инструкций для отладки, используйте logger.debug: В отличие от операторов print, которые вам придется удалить или закомментировать позже, инструкции logger.debug можно оставить в исходном коде и оставить бездействующими, пока вам не понадобятся. В этот момент единственное изменение, которое необходимо внести, это изменить уровень серьезности регистратора и/или обработчика на отладку.

Ведение журнала в несколько мест назначения

Допустим, вы хотите вести журнал в консоль и в файл с различными форматами сообщений и в разных обстоятельствах. Предположим, вы хотите вести журнал сообщений с уровнями DEBUG и выше в файл и сообщений с уровнем INFO и выше в консоль. Предположим также, что файл должен содержать отметки времени, а сообщения в консоли — нет. Вот как можно этого добиться:

import logging

# set up logging to file - see previous section for more details
logging.basicConfig(level=logging.DEBUG,
                    format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
                    datefmt='%m-%d %H:%M',
                    filename='/tmp/myapp.log',
                    filemode='w')
# define a Handler which writes INFO messages or higher to the sys.stderr
console = logging.StreamHandler()
console.setLevel(logging.INFO)
# set a format which is simpler for console use
formatter = logging.Formatter('%(name)-12s: %(levelname)-8s %(message)s')
# tell the handler to use this format
console.setFormatter(formatter)
# add the handler to the root logger
logging.getLogger('').addHandler(console)

# Now, we can log to the root logger, or any other logger. First the root...
logging.info('Jackdaws love my big sphinx of quartz.')

# Now, define a couple of other loggers which might represent areas in your
# application:

logger1 = logging.getLogger('myapp.area1')
logger2 = logging.getLogger('myapp.area2')

logger1.debug('Quick zephyrs blow, vexing daft Jim.')
logger1.info('How quickly daft jumping zebras vex.')
logger2.warning('Jail zesty vixen who grabbed pay from quack.')
logger2.error('The five boxing wizards jump quickly.')

При запуске в консоли вы увидите

root        : INFO     Jackdaws love my big sphinx of quartz.
myapp.area1 : INFO     How quickly daft jumping zebras vex.
myapp.area2 : WARNING  Jail zesty vixen who grabbed pay from quack.
myapp.area2 : ERROR    The five boxing wizards jump quickly.

а в файле вы увидите что-то вроде

10-22 22:19 root         INFO     Jackdaws love my big sphinx of quartz.
10-22 22:19 myapp.area1  DEBUG    Quick zephyrs blow, vexing daft Jim.
10-22 22:19 myapp.area1  INFO     How quickly daft jumping zebras vex.
10-22 22:19 myapp.area2  WARNING  Jail zesty vixen who grabbed pay from quack.
10-22 22:19 myapp.area2  ERROR    The five boxing wizards jump quickly.

Как видите, сообщение DEBUG отображается только в файле. Другие сообщения отправляются в оба места назначения.

В этом примере используются обработчики консоли и файла, но вы можете использовать любое количество и комбинацию обработчиков по вашему выбору.

Обратите внимание, что вышеупомянутый выбор имени файла журнала /tmp/myapp.log подразумевает использование стандартного расположения для временных файлов в системах POSIX. В Windows вам может потребоваться выбрать другое имя каталога для журнала — просто убедитесь, что каталог существует и у вас есть разрешения на создание и обновление файлов в нем.

Настройка обработки уровней

Иногда вам может потребоваться сделать что-то немного отличное от стандартной обработки уровней в обработчиках, где все уровни, превышающие порог, обрабатываются обработчиком. Для этого вам необходимо использовать фильтры. Рассмотрим сценарий, где вы хотите организовать вещи следующим образом:

  • Отправлять сообщения с уровнем серьезности INFO и WARNING в sys.stdout
  • Отправлять сообщения с уровнем серьезности ERROR и выше в sys.stderr
  • Отправлять сообщения с уровнем серьезности DEBUG и выше в файл app.log

Предположим, вы настраиваете ведение журнала с помощью следующего JSON:

{
    "version": 1,
    "disable_existing_loggers": false,
    "formatters": {
        "simple": {
            "format": "%(levelname)-8s - %(message)s"
        }
    },
    "handlers": {
        "stdout": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "simple",
            "stream": "ext://sys.stdout"
        },
        "stderr": {
            "class": "logging.StreamHandler",
            "level": "ERROR",
            "formatter": "simple",
            "stream": "ext://sys.stderr"
        },
        "file": {
            "class": "logging.FileHandler",
            "formatter": "simple",
            "filename": "app.log",
            "mode": "w"
        }
    },
    "root": {
        "level": "DEBUG",
        "handlers": [
            "stderr",
            "stdout",
            "file"
        ]
    }
}

Эта конфигурация почти делает то, что мы хотим, за исключением того, что sys.stdout отобразит сообщения с уровнем серьезности ERROR и выше, а также сообщения INFO и WARNING. Чтобы предотвратить это, мы можем настроить фильтр, исключающий эти сообщения, и добавить его к соответствующему обработчику. Это можно настроить, добавив раздел filters параллельно с formatters и handlers:

{
    "filters": {
        "warnings_and_below": {
            "()" : "__main__.filter_maker",
            "level": "WARNING"
        }
    }
}

и изменив раздел обработчика stdout для добавления его:

{
    "stdout": {
        "class": "logging.StreamHandler",
        "level": "INFO",
        "formatter": "simple",
        "stream": "ext://sys.stdout",
        "filters": ["warnings_and_below"]
    }
}

Фильтр — это просто функция, поэтому мы можем определить filter_maker (функцию-фабрику) следующим образом:

def filter_maker(level):
    level = getattr(logging, level)

    def filter(record):
        return record.levelno <= level

    return filter

Это преобразует строковый аргумент, переданный в числовой уровень, и возвращает функцию, которая возвращает только True в том случае, если уровень переданного запися является меньше или равен указанному уровню. Обратите внимание, что в этом примере я определил filter_maker в тестовом скрипте main.py, который я запускаю из командной строки, поэтому его модуль будет __main__ — отсюда и __main__.filter_maker в конфигурации фильтра. Вам нужно будет изменить это, если вы определите его в другом модуле.

С добавленным фильтром мы можем запустить main.py, что в полном объеме:

import json
import logging
import logging.config

CONFIG = '''
{
    "version": 1,
    "disable_existing_loggers": false,
    "formatters": {
        "simple": {
            "format": "%(levelname)-8s - %(message)s"
        }
    },
    "filters": {
        "warnings_and_below": {
            "()" : "__main__.filter_maker",
            "level": "WARNING"
        }
    },
    "handlers": {
        "stdout": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "simple",
            "stream": "ext://sys.stdout",
            "filters": ["warnings_and_below"]
        },
        "stderr": {
            "class": "logging.StreamHandler",
            "level": "ERROR",
            "formatter": "simple",
            "stream": "ext://sys.stderr"
        },
        "file": {
            "class": "logging.FileHandler",
            "formatter": "simple",
            "filename": "app.log",
            "mode": "w"
        }
    },
    "root": {
        "level": "DEBUG",
        "handlers": [
            "stderr",
            "stdout",
            "file"
        ]
    }
}
'''

def filter_maker(level):
    level = getattr(logging, level)

    def filter(record):
        return record.levelno <= level

    return filter

logging.config.dictConfig(json.loads(CONFIG))
logging.debug('A DEBUG message')
logging.info('An INFO message')
logging.warning('A WARNING message')
logging.error('An ERROR message')
logging.critical('A CRITICAL message')

А после запуска его так:

python main.py 2>stderr.log >stdout.log

Мы можем видеть, что результаты соответствуют ожиданиям:

$ more *.log
::::::::::::::
app.log
::::::::::::::
DEBUG    - A DEBUG message
INFO     - An INFO message
WARNING  - A WARNING message
ERROR    - An ERROR message
CRITICAL - A CRITICAL message
::::::::::::::
stderr.log
::::::::::::::
ERROR    - An ERROR message
CRITICAL - A CRITICAL message
::::::::::::::
stdout.log
::::::::::::::
INFO     - An INFO message
WARNING  - A WARNING message

Пример сервера конфигурации

Вот пример модуля, использующего сервер конфигурации ведения журнала:

import logging
import logging.config
import time
import os

# read initial config file
logging.config.fileConfig('logging.conf')

# create and start listener on port 9999
t = logging.config.listen(9999)
t.start()

logger = logging.getLogger('simpleExample')

try:
    # loop through logging calls to see the difference
    # new configurations make, until Ctrl+C is pressed
    while True:
        logger.debug('debug message')
        logger.info('info message')
        logger.warning('warn message')
        logger.error('error message')
        logger.critical('critical message')
        time.sleep(5)
except KeyboardInterrupt:
    # cleanup
    logging.config.stopListening()
    t.join()

И вот скрипт, который принимает имя файла и отправляет этот файл на сервер, предварительно закодировав длину в двоичном виде, как новую конфигурацию ведения журнала:

#!/usr/bin/env python
import socket, sys, struct

with open(sys.argv[1], 'rb') as f:
    data_to_send = f.read()

HOST = 'localhost'
PORT = 9999
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print('connecting...')
s.connect((HOST, PORT))
print('sending config...')
s.send(struct.pack('>L', len(data_to_send)))
s.send(data_to_send)
s.close()
print('complete')
END_OF_DOCUMENT_MARKER

Обработка обработчиков, которые блокируют

Иногда вам нужно заставить обработчики регистрации выполнять свою работу без блокирования потока, из которого ведётся регистрация. Это часто встречается в веб-приложениях, хотя, конечно, это происходит и в других сценариях.

Частым виновником, демонстрирующим медленную работу, является SMTPHandler: отправка электронных писем может занять много времени по ряду причин, не зависящих от разработчика (например, плохо работающая почтовая или сетевая инфраструктура). Но почти любой обработчик, основанный на сети, может блокировать: даже операция SocketHandler может в скрытом режиме выполнять запрос DNS, который слишком медленный (и этот запрос может быть глубоко в коде библиотеки сокетов, ниже уровня Python и вне вашего контроля).

Одно из решений — использовать двухэтапный подход. На первом этапе подключите только QueueHandler к тем логгерам, к которым осуществляется доступ из критически важных для производительности потоков. Они просто записывают в свою очередь, которая может быть настроена на достаточно большой объём или инициализирована без верхнего предела размера. Запись в очередь обычно принимается быстро, хотя вам, вероятно, придётся перехватить исключение queue.Full в качестве меры предосторожности в вашем коде. Если вы являетесь разработчиком библиотеки, у которой есть критически важные для производительности потоки в коде, обязательно документируйте это (вместе с предложением подключить только QueueHandlers к вашим логгерам) для пользы других разработчиков, которые будут использовать ваш код.

Второй этап решения — QueueListener, который разработан как аналог QueueHandler. QueueListener очень прост: ему передаётся очередь и некоторые обработчики, и он запускает внутренний поток, который прослушивает свою очередь в поисках LogRecords, отправленных от QueueHandlers (или любого другого источника LogRecords, если хотите). LogRecords удаляются из очереди и передаются обработчикам для обработки.

Преимущества наличия отдельного класса QueueListener заключаются в том, что вы можете использовать один экземпляр для обслуживания нескольких QueueHandlers. Это более экономично с точки зрения ресурсов, чем, скажем, использование потоковых версий существующих классов обработчиков, которые будут использовать по одному потоку на обработчик без какой-либо особой пользы.

Следующий пример использования этих двух классов (импорты опушены):

que = queue.Queue(-1)  # no limit on size
queue_handler = QueueHandler(que)
handler = logging.StreamHandler()
listener = QueueListener(que, handler)
root = logging.getLogger()
root.addHandler(queue_handler)
formatter = logging.Formatter('%(threadName)s: %(message)s')
handler.setFormatter(formatter)
listener.start()
# The log output will display the thread which generated
# the event (the main thread) rather than the internal
# thread which monitors the internal queue. This is what
# you want to happen.
root.warning('Look out!')
listener.stop()

который при запуске выдаст:

MainThread: Look out!

Примечание

Хотя предыдущая дискуссия не была конкретно о коде асинхронного выполнения, а скорее о медленных обработчиках регистрации, следует отметить, что при регистрации из асинхронного кода обработчики сети и даже файлов могут привести к проблемам (блокировке цикла событий), поскольку некоторые операции регистрации выполняются из компонентов asyncio. Возможно, если в приложении используется асинхронный код, лучше использовать вышеописанный подход для регистрации, чтобы любой блокирующий код выполнялся только в потоке QueueListener.

Изменено в версии 3.5: До Python 3.5 QueueListener всегда передавал каждое сообщение, полученное из очереди, каждому обработчику, с которым он был инициализирован. (Это было связано с тем, что предполагалось, что фильтрация уровней выполняется с другой стороны, где заполняется очередь.) Начиная с версии 3.5, это поведение можно изменить, передав ключевой аргумент respect_handler_level=True в конструктор слушателя. В этом случае слушатель сравнивает уровень каждого сообщения с уровнем обработчика и передаёт сообщение обработчику только в том случае, если это уместно.

Отправка и получение событий регистрации по сети

Предположим, вы хотите отправлять события регистрации по сети и обрабатывать их на принимающей стороне. Простым способом сделать это является подключение экземпляра SocketHandler к корневому логгеру на стороне отправки:

import logging, logging.handlers

rootLogger = logging.getLogger('')
rootLogger.setLevel(logging.DEBUG)
socketHandler = logging.handlers.SocketHandler('localhost',
                    logging.handlers.DEFAULT_TCP_LOGGING_PORT)
# don't bother with a formatter, since a socket handler sends the event as
# an unformatted pickle
rootLogger.addHandler(socketHandler)

# Now, we can log to the root logger, or any other logger. First the root...
logging.info('Jackdaws love my big sphinx of quartz.')

# Now, define a couple of other loggers which might represent areas in your
# application:

logger1 = logging.getLogger('myapp.area1')
logger2 = logging.getLogger('myapp.area2')

logger1.debug('Quick zephyrs blow, vexing daft Jim.')
logger1.info('How quickly daft jumping zebras vex.')
logger2.warning('Jail zesty vixen who grabbed pay from quack.')
logger2.error('The five boxing wizards jump quickly.')

На принимающей стороне можно настроить приёмник, используя модуль socketserver. Вот простой рабочий пример:

import pickle
import logging
import logging.handlers
import socketserver
import struct


class LogRecordStreamHandler(socketserver.StreamRequestHandler):
    """Handler for a streaming logging request.

    This basically logs the record using whatever logging policy is
    configured locally.
    """

    def handle(self):
        """
        Handle multiple requests - each expected to be a 4-byte length,
        followed by the LogRecord in pickle format. Logs the record
        according to whatever policy is configured locally.
        """
        while True:
            chunk = self.connection.recv(4)
            if len(chunk) < 4:
                break
            slen = struct.unpack('>L', chunk)[0]
            chunk = self.connection.recv(slen)
            while len(chunk) < slen:
                chunk = chunk + self.connection.recv(slen - len(chunk))
            obj = self.unPickle(chunk)
            record = logging.makeLogRecord(obj)
            self.handleLogRecord(record)

    def unPickle(self, data):
        return pickle.loads(data)

    def handleLogRecord(self, record):
        # if a name is specified, we use the named logger rather than the one
        # implied by the record.
        if self.server.logname is not None:
            name = self.server.logname
        else:
            name = record.name
        logger = logging.getLogger(name)
        # N.B. EVERY record gets logged. This is because Logger.handle
        # is normally called AFTER logger-level filtering. If you want
        # to do filtering, do it at the client end to save wasting
        # cycles and network bandwidth!
        logger.handle(record)

class LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
    """
    Simple TCP socket-based logging receiver suitable for testing.
    """

    allow_reuse_address = True

    def __init__(self, host='localhost',
                 port=logging.handlers.DEFAULT_TCP_LOGGING_PORT,
                 handler=LogRecordStreamHandler):
        socketserver.ThreadingTCPServer.__init__(self, (host, port), handler)
        self.abort = 0
        self.timeout = 1
        self.logname = None

    def serve_until_stopped(self):
        import select
        abort = 0
        while not abort:
            rd, wr, ex = select.select([self.socket.fileno()],
                                       [], [],
                                       self.timeout)
            if rd:
                self.handle_request()
            abort = self.abort

def main():
    logging.basicConfig(
        format='%(relativeCreated)5d %(name)-15s %(levelname)-8s %(message)s')
    tcpserver = LogRecordSocketReceiver()
    print('About to start TCP server...')
    tcpserver.serve_until_stopped()

if __name__ == '__main__':
    main()

Сначала запустите сервер, а затем клиента. На стороне клиента ничего не выводится на консоль; на стороне сервера вы должны увидеть что-то вроде:

About to start TCP server...
   59 root            INFO     Jackdaws love my big sphinx of quartz.
   59 myapp.area1     DEBUG    Quick zephyrs blow, vexing daft Jim.
   69 myapp.area1     INFO     How quickly daft jumping zebras vex.
   69 myapp.area2     WARNING  Jail zesty vixen who grabbed pay from quack.
   69 myapp.area2     ERROR    The five boxing wizards jump quickly.

Обратите внимание, что в некоторых сценариях с pickle существуют проблемы безопасности. Если это вас касается, вы можете использовать альтернативную схему сериализации, переопределив метод makePickle() и реализовав там вашу альтернативу, а также адаптировав вышеприведённый скрипт для использования вашей альтернативной сериализации.

Запуск прослушивателя сокетов регистрации в рабочей среде

Для запуска прослушивателя регистрации в рабочей среде вам может потребоваться использовать инструмент управления процессами, такой как Supervisor. Вот Gist, который предоставляет базовые файлы для запуска вышеупомянутой функциональности с помощью Supervisor. Он состоит из следующих файлов:

Файл

Назначение

prepare.sh

Скрипт Bash для подготовки среды для тестирования

supervisor.conf

Файл конфигурации Supervisor, содержащий записи для прослушивателя и многопроцессного веб-приложения

ensure_app.sh

Скрипт Bash для обеспечения запуска Supervisor с указанной выше конфигурацией

log_listener.py

Программа прослушивателя сокетов, которая получает события регистрации и записывает их в файл

main.py

Простое веб-приложение, которое выполняет регистрацию через сокет, подключённый к прослушивателю

webapp.json

Файл конфигурации в формате JSON для веб-приложения

client.py

Скрипт Python для тестирования веб-приложения

Веб-приложение использует Gunicorn, что является популярным веб-сервером приложений, запускающим несколько рабочих процессов для обработки запросов. Этот пример показывает, как рабочие процессы могут писать в один и тот же лог-файл, не конфликтуя друг с другом — все они проходят через прослушиватель сокета.

Для проверки этих файлов выполните следующие действия в среде POSIX:

  1. Загрузите Gist в виде архива ZIP, используя кнопку Скачать ZIP.
  2. Распакуйте вышеперечисленные файлы из архива в каталог для временного хранения.
  3. В каталоге для временного хранения запустите bash prepare.sh для подготовки. Это создаёт подкаталог run для хранения файлов Supervisor и логов, и подкаталог venv для виртуальной среды, в которую устанавливаются bottle, gunicorn и supervisor.
  4. Запустите bash ensure_app.sh для обеспечения запуска Supervisor с указанной конфигурацией.
  5. Запустите venv/bin/python client.py для тестирования веб-приложения, что приведёт к записи записей в лог.
  6. Проверьте лог-файлы в подкаталоге run. Вы должны увидеть самые последние строки лога в файлах, соответствующих шаблону app.log*. Они не будут в определённом порядке, так как они обрабатывались различными рабочими процессами в непредсказуемый способ.
  7. Вы можете остановить прослушиватель и веб-приложение, запустив venv/bin/supervisorctl -c supervisor.conf shutdown.

Возможно, вам придётся скорректировать файлы конфигурации в том маловероятном случае, если настроенные порты конфликтуют с чем-то другим в вашей тестовой среде.

Добавление контекстной информации в ваш вывод логов

Иногда вам нужно, чтобы вывод логов содержал контекстную информацию помимо параметров, переданных в вызов логгирования. Например, в сетевом приложении может быть желательно регистрировать клиентскую информацию в логе (например, имя пользователя удаленного клиента или его IP-адрес). Хотя вы можете использовать параметр extra для достижения этого, это не всегда удобно. Хотя может показаться заманчивым создавать экземпляры Logger для каждого подключения, это не хорошая идея, так как такие экземпляры не собираются сборщиком мусора. Хотя на практике это не проблема, когда количество экземпляров Logger зависит от уровня детализации, который вы хотите использовать при регистрации приложения, это может быть трудно управлять, если количество экземпляров Logger станет фактически неограниченным.

Использование LoggerAdapters для передачи контекстной информации

Легкий способ передачи контекстной информации для вывода вместе с информацией о событии логгирования — это использовать класс LoggerAdapter. Этот класс предназначен для работы как Logger, так что вы можете вызывать debug(), info(), warning(), error(), exception(), critical() и log(). Эти методы имеют тот же сигнатура, что и их аналоги в Logger, поэтому вы можете использовать оба типа экземпляров взаимозаменяемо.

При создании экземпляра LoggerAdapter, вы передаете ему экземпляр Logger и объект, похожий на словарь, который содержит вашу контекстную информацию. Когда вы вызываете один из методов логгирования на экземпляре LoggerAdapter, он делегирует вызов базовому экземпляру Logger, переданному в его конструктор, и организует передачу контекстной информации в делегированном вызове. Вот фрагмент кода из LoggerAdapter:

def debug(self, msg, /, *args, **kwargs):
    """
    Delegate a debug call to the underlying logger, after adding
    contextual information from this adapter instance.
    """
    msg, kwargs = self.process(msg, kwargs)
    self.logger.debug(msg, *args, **kwargs)

Метод process() класса LoggerAdapter — это место, где контекстная информация добавляется в вывод логов. Ему передается сообщение и ключевые аргументы вызова логгирования, и он возвращает (возможно) измененные версии этих аргументов для использования в вызове базового логгера. По умолчанию этот метод оставляет сообщение без изменений, но вставляет ключ 'extra' в аргументы ключевых слов, значение которого — объект, похожий на словарь, переданный в конструктор. Конечно, если вы передали ключевой аргумент 'extra' в вызов адаптера, он будет незаметно перезаписан.

Преимущество использования 'extra' заключается в том, что значения в объекте, похожем на словарь, объединяются в экземпляр LogRecord.__dict__, что позволяет использовать настраиваемые строки с вашими экземплярами Formatter, которые знают о ключах объекта, подобного словарю. Если вам нужен другой метод, например, если вы хотите добавить контекстную информацию в начало или конец строки сообщения, вам просто нужно создать подкласс LoggerAdapter и переопределить process() для выполнения необходимых задач. Вот простой пример:

class CustomAdapter(logging.LoggerAdapter):
    """
    This example adapter expects the passed in dict-like object to have a
    'connid' key, whose value in brackets is prepended to the log message.
    """
    def process(self, msg, kwargs):
        return '[%s] %s' % (self.extra['connid'], msg), kwargs

который можно использовать так:

logger = logging.getLogger(__name__)
adapter = CustomAdapter(logger, {'connid': some_conn_id})

Тогда любые события, которые вы регистрируете в адаптере, будут иметь значение some_conn_id в начале сообщений в логе.

Использование объектов, отличных от словарей, для передачи контекстной информации

Вам не нужно передавать фактический словарь в LoggerAdapter — вы можете передать экземпляр класса, реализующего __getitem__ и __iter__, чтобы он выглядел как словарь для logging. Это будет полезно, если вы хотите динамически генерировать значения (в то время как значения в словаре будут постоянными).

Использование фильтров для передачи контекстной информации

Вы также можете добавлять контекстную информацию в вывод логов, используя пользовательский Filter. Filter экземпляры могут изменять LogRecords, передаваемые им, включая добавление дополнительных атрибутов, которые затем могут быть выведены с помощью подходящей строки формата или, при необходимости, настраиваемого Formatter.

Например, в веб-приложении обрабатываемый запрос (или, по крайней мере, интересные его части) можно хранить в переменной threadlocal (threading.local), а затем получать доступ к ней из Filter для добавления, например, информации из запроса — например, удаленного IP-адреса и имени пользователя удаленного клиента — в LogRecord, используя имена атрибутов ‘ip’ и ‘user’, как в примере LoggerAdapter выше. В этом случае можно использовать ту же строку формата для получения аналогичного вывода, как показано выше. Вот пример скрипта:

import logging
from random import choice

class ContextFilter(logging.Filter):
    """
    This is a filter which injects contextual information into the log.

    Rather than use actual contextual information, we just use random
    data in this demo.
    """

    USERS = ['jim', 'fred', 'sheila']
    IPS = ['123.231.231.123', '127.0.0.1', '192.168.0.1']

    def filter(self, record):

        record.ip = choice(ContextFilter.IPS)
        record.user = choice(ContextFilter.USERS)
        return True

if __name__ == '__main__':
    levels = (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL)
    logging.basicConfig(level=logging.DEBUG,
                        format='%(asctime)-15s %(name)-5s %(levelname)-8s IP: %(ip)-15s User: %(user)-8s %(message)s')
    a1 = logging.getLogger('a.b.c')
    a2 = logging.getLogger('d.e.f')

    f = ContextFilter()
    a1.addFilter(f)
    a2.addFilter(f)
    a1.debug('A debug message')
    a1.info('An info message with %s', 'some parameters')
    for x in range(10):
        lvl = choice(levels)
        lvlname = logging.getLevelName(lvl)
        a2.log(lvl, 'A message at %s level with %d %s', lvlname, 2, 'parameters')

который при запуске выведет что-то вроде:

2010-09-06 22:38:15,292 a.b.c DEBUG    IP: 123.231.231.123 User: fred     A debug message
2010-09-06 22:38:15,300 a.b.c INFO     IP: 192.168.0.1     User: sheila   An info message with some parameters
2010-09-06 22:38:15,300 d.e.f CRITICAL IP: 127.0.0.1       User: sheila   A message at CRITICAL level with 2 parameters
2010-09-06 22:38:15,300 d.e.f ERROR    IP: 127.0.0.1       User: jim      A message at ERROR level with 2 parameters
2010-09-06 22:38:15,300 d.e.f DEBUG    IP: 127.0.0.1       User: sheila   A message at DEBUG level with 2 parameters
2010-09-06 22:38:15,300 d.e.f ERROR    IP: 123.231.231.123 User: fred     A message at ERROR level with 2 parameters
2010-09-06 22:38:15,300 d.e.f CRITICAL IP: 192.168.0.1     User: jim      A message at CRITICAL level with 2 parameters
2010-09-06 22:38:15,300 d.e.f CRITICAL IP: 127.0.0.1       User: sheila   A message at CRITICAL level with 2 parameters
2010-09-06 22:38:15,300 d.e.f DEBUG    IP: 192.168.0.1     User: jim      A message at DEBUG level with 2 parameters
2010-09-06 22:38:15,301 d.e.f ERROR    IP: 127.0.0.1       User: sheila   A message at ERROR level with 2 parameters
2010-09-06 22:38:15,301 d.e.f DEBUG    IP: 123.231.231.123 User: fred     A message at DEBUG level with 2 parameters
2010-09-06 22:38:15,301 d.e.f INFO     IP: 123.231.231.123 User: fred     A message at INFO level with 2 parameters

Использование contextvars

С Python 3.7 модуль contextvars предоставляет контекстно-локальное хранилище, которое работает как для threading, так и для asyncio задач. Этот тип хранения, таким образом, может быть предпочтительнее локальных переменных потока.

Следующий пример показывает, как в многопоточной среде логи могут быть заполнены контекстной информацией, такой как, например, атрибуты запроса, обрабатываемые веб-приложениями.

Для целей иллюстрации предположим, что у вас есть разные веб-приложения, каждое из которых независимо от другого, но выполняющиеся в одном процессе Python и использующие общую для них библиотеку. Как каждое из этих приложений может иметь свой собственный лог, где все сообщения логгирования из библиотеки (и другого кода обработки запросов) направляются в соответствующий лог-файл приложения, при этом включая дополнительную контекстную информацию, такую как IP-адрес клиента, метод HTTP-запроса и имя пользователя клиента?

Предположим, что библиотеку можно смоделировать следующим кодом:

# webapplib.py
import logging
import time

logger = logging.getLogger(__name__)

def useful():
    # Just a representative event logged from the library
    logger.debug('Hello from webapplib!')
    # Just sleep for a bit so other threads get to run
    time.sleep(0.01)

Мы можем смоделировать несколько веб-приложений с помощью двух простых классов, Request и WebApp. Они моделируют работу реальных многопоточных веб-приложений — каждый запрос обрабатывается потоком:

# main.py
import argparse
from contextvars import ContextVar
import logging
import os
from random import choice
import threading
import webapplib

logger = logging.getLogger(__name__)
root = logging.getLogger()
root.setLevel(logging.DEBUG)

class Request:
    """
    A simple dummy request class which just holds dummy HTTP request method,
    client IP address and client username
    """
    def __init__(self, method, ip, user):
        self.method = method
        self.ip = ip
        self.user = user

# A dummy set of requests which will be used in the simulation - we'll just pick
# from this list randomly. Note that all GET requests are from 192.168.2.XXX
# addresses, whereas POST requests are from 192.16.3.XXX addresses. Three users
# are represented in the sample requests.

REQUESTS = [
    Request('GET', '192.168.2.20', 'jim'),
    Request('POST', '192.168.3.20', 'fred'),
    Request('GET', '192.168.2.21', 'sheila'),
    Request('POST', '192.168.3.21', 'jim'),
    Request('GET', '192.168.2.22', 'fred'),
    Request('POST', '192.168.3.22', 'sheila'),
]

# Note that the format string includes references to request context information
# such as HTTP method, client IP and username

formatter = logging.Formatter('%(threadName)-11s %(appName)s %(name)-9s %(user)-6s %(ip)s %(method)-4s %(message)s')

# Create our context variables. These will be filled at the start of request
# processing, and used in the logging that happens during that processing

ctx_request = ContextVar('request')
ctx_appname = ContextVar('appname')

class InjectingFilter(logging.Filter):
    """
    A filter which injects context-specific information into logs and ensures
    that only information for a specific webapp is included in its log
    """
    def __init__(self, app):
        self.app = app

    def filter(self, record):
        request = ctx_request.get()
        record.method = request.method
        record.ip = request.ip
        record.user = request.user
        record.appName = appName = ctx_appname.get()
        return appName == self.app.name

class WebApp:
    """
    A dummy web application class which has its own handler and filter for a
    webapp-specific log.
    """
    def __init__(self, name):
        self.name = name
        handler = logging.FileHandler(name + '.log', 'w')
        f = InjectingFilter(self)
        handler.setFormatter(formatter)
        handler.addFilter(f)
        root.addHandler(handler)
        self.num_requests = 0

    def process_request(self, request):
        """
        This is the dummy method for processing a request. It's called on a
        different thread for every request. We store the context information into
        the context vars before doing anything else.
        """
        ctx_request.set(request)
        ctx_appname.set(self.name)
        self.num_requests += 1
        logger.debug('Request processing started')
        webapplib.useful()
        logger.debug('Request processing finished')

def main():
    fn = os.path.splitext(os.path.basename(__file__))[0]
    adhf = argparse.ArgumentDefaultsHelpFormatter
    ap = argparse.ArgumentParser(formatter_class=adhf, prog=fn,
                                 description='Simulate a couple of web '
                                             'applications handling some '
                                             'requests, showing how request '
                                             'context can be used to '
                                             'populate logs')
    aa = ap.add_argument
    aa('--count', '-c', type=int, default=100, help='How many requests to simulate')
    options = ap.parse_args()

    # Create the dummy webapps and put them in a list which we can use to select
    # from randomly
    app1 = WebApp('app1')
    app2 = WebApp('app2')
    apps = [app1, app2]
    threads = []
    # Add a common handler which will capture all events
    handler = logging.FileHandler('app.log', 'w')
    handler.setFormatter(formatter)
    root.addHandler(handler)

    # Generate calls to process requests
    for i in range(options.count):
        try:
            # Pick an app at random and a request for it to process
            app = choice(apps)
            request = choice(REQUESTS)
            # Process the request in its own thread
            t = threading.Thread(target=app.process_request, args=(request,))
            threads.append(t)
            t.start()
        except KeyboardInterrupt:
            break

    # Wait for the threads to terminate
    for t in threads:
        t.join()

    for app in apps:
        print('%s processed %s requests' % (app.name, app.num_requests))

if __name__ == '__main__':
    main()

Если вы запустите вышеприведенное, вы должны обнаружить, что примерно половина запросов попадает в app1.log, а остальные — в app2.log, и все запросы записываются в app.log. Каждый веб-приложение-специфичный лог будет содержать только записи логов только для этого веб-приложения, а информация о запросе будет отображаться последовательно в логе (то есть информация в каждом фальшивом запросе всегда будет появляться вместе в строке лога). Это показано в следующем выводе оболочки:

~/logging-contextual-webapp$ python main.py
app1 processed 51 requests
app2 processed 49 requests
~/logging-contextual-webapp$ wc -l *.log
  153 app1.log
  147 app2.log
  300 app.log
  600 total
~/logging-contextual-webapp$ head -3 app1.log
Thread-3 (process_request) app1 __main__  jim    192.168.3.21 POST Request processing started
Thread-3 (process_request) app1 webapplib jim    192.168.3.21 POST Hello from webapplib!
Thread-5 (process_request) app1 __main__  jim    192.168.3.21 POST Request processing started
~/logging-contextual-webapp$ head -3 app2.log
Thread-1 (process_request) app2 __main__  sheila 192.168.2.21 GET  Request processing started
Thread-1 (process_request) app2 webapplib sheila 192.168.2.21 GET  Hello from webapplib!
Thread-2 (process_request) app2 __main__  jim    192.168.2.20 GET  Request processing started
~/logging-contextual-webapp$ head app.log
Thread-1 (process_request) app2 __main__  sheila 192.168.2.21 GET  Request processing started
Thread-1 (process_request) app2 webapplib sheila 192.168.2.21 GET  Hello from webapplib!
Thread-2 (process_request) app2 __main__  jim    192.168.2.20 GET  Request processing started
Thread-3 (process_request) app1 __main__  jim    192.168.3.21 POST Request processing started
Thread-2 (process_request) app2 webapplib jim    192.168.2.20 GET  Hello from webapplib!
Thread-3 (process_request) app1 webapplib jim    192.168.3.21 POST Hello from webapplib!
Thread-4 (process_request) app2 __main__  fred   192.168.2.22 GET  Request processing started
Thread-5 (process_request) app1 __main__  jim    192.168.3.21 POST Request processing started
Thread-4 (process_request) app2 webapplib fred   192.168.2.22 GET  Hello from webapplib!
Thread-6 (process_request) app1 __main__  jim    192.168.3.21 POST Request processing started
~/logging-contextual-webapp$ grep app1 app1.log | wc -l
153
~/logging-contextual-webapp$ grep app2 app2.log | wc -l
147
~/logging-contextual-webapp$ grep app1 app.log | wc -l
153
~/logging-contextual-webapp$ grep app2 app.log | wc -l
147

Передача контекстной информации в обработчиках

Каждый Handler имеет свою цепочку фильтров. Если вы хотите добавить контекстную информацию в LogRecord без утечки ее другим обработчикам, вы можете использовать фильтр, который возвращает новый LogRecord вместо его модификации на месте, как показано в следующем скрипте:

import copy
import logging

def filter(record: logging.LogRecord):
    record = copy.copy(record)
    record.user = 'jim'
    return record

if __name__ == '__main__':
    logger = logging.getLogger()
    logger.setLevel(logging.INFO)
    handler = logging.StreamHandler()
    formatter = logging.Formatter('%(message)s from %(user)-8s')
    handler.setFormatter(formatter)
    handler.addFilter(filter)
    logger.addHandler(handler)

    logger.info('A log message')

Ведение журнала в один файл из нескольких процессов

Хотя ведение журнала является потокобезопасным, и ведение журнала в один файл из нескольких потоков в одном процессе поддерживается, ведение журнала в один файл из нескольких процессов не поддерживается, потому что нет стандартного способа сериализовать доступ к одному файлу через несколько процессов в Python. Если вам нужно вести журнал в один файл из нескольких процессов, один из способов сделать это — заставить все процессы записывать в SocketHandler, а отдельный процесс реализует сокет-сервер, который считывает из сокета и записывает в файл. (Если хотите, вы можете выделить один поток в одном из существующих процессов для выполнения этой функции.) Этот раздел документирует этот подход более подробно и включает в себя работающий сокет-приемник, который можно использовать в качестве отправной точки для адаптации в ваших собственных приложениях.

Вы также можете написать свой собственный обработчик, который использует класс Lock из модуля multiprocessing для сериализации доступа к файлу из ваших процессов. Существующие FileHandler и подклассы в настоящее время не используют multiprocessing, хотя в будущем они могут это сделать. Обратите внимание, что в настоящее время модуль multiprocessing не предоставляет работоспособную функциональность блокировки на всех платформах (см. https://bugs.python.org/issue3770).

В качестве альтернативы, вы можете использовать Queue и QueueHandler для отправки всех событий ведения журнала в один из процессов в вашем многопроцессном приложении. Следующий пример скрипта демонстрирует, как это можно сделать; в примере отдельный процесс-слушатель прослушивает события, отправленные другими процессами, и записывает их в соответствии с собственной конфигурацией ведения журнала. Хотя пример демонстрирует только один способ сделать это (например, вы можете использовать поток-слушатель вместо отдельного процесса-слушателя — реализация будет аналогичной), он позволяет использовать совершенно разные конфигурации ведения журнала для слушателя и других процессов в вашем приложении и может быть использован в качестве основы для кода, удовлетворяющего вашим конкретным требованиям:

# You'll need these imports in your own code
import logging
import logging.handlers
import multiprocessing

# Next two import lines for this demo only
from random import choice, random
import time

#
# Because you'll want to define the logging configurations for listener and workers, the
# listener and worker process functions take a configurer parameter which is a callable
# for configuring logging for that process. These functions are also passed the queue,
# which they use for communication.
#
# In practice, you can configure the listener however you want, but note that in this
# simple example, the listener does not apply level or filter logic to received records.
# In practice, you would probably want to do this logic in the worker processes, to avoid
# sending events which would be filtered out between processes.
#
# The size of the rotated files is made small so you can see the results easily.
def listener_configurer():
    root = logging.getLogger()
    h = logging.handlers.RotatingFileHandler('mptest.log', 'a', 300, 10)
    f = logging.Formatter('%(asctime)s %(processName)-10s %(name)s %(levelname)-8s %(message)s')
    h.setFormatter(f)
    root.addHandler(h)

# This is the listener process top-level loop: wait for logging events
# (LogRecords)on the queue and handle them, quit when you get a None for a
# LogRecord.
def listener_process(queue, configurer):
    configurer()
    while True:
        try:
            record = queue.get()
            if record is None:  # We send this as a sentinel to tell the listener to quit.
                break
            logger = logging.getLogger(record.name)
            logger.handle(record)  # No level or filter logic applied - just do it!
        except Exception:
            import sys, traceback
            print('Whoops! Problem:', file=sys.stderr)
            traceback.print_exc(file=sys.stderr)

# Arrays used for random selections in this demo

LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING,
          logging.ERROR, logging.CRITICAL]

LOGGERS = ['a.b.c', 'd.e.f']

MESSAGES = [
    'Random message #1',
    'Random message #2',
    'Random message #3',
]

# The worker configuration is done at the start of the worker process run.
# Note that on Windows you can't rely on fork semantics, so each process
# will run the logging configuration code when it starts.
def worker_configurer(queue):
    h = logging.handlers.QueueHandler(queue)  # Just the one handler needed
    root = logging.getLogger()
    root.addHandler(h)
    # send all messages, for demo; no other level or filter logic applied.
    root.setLevel(logging.DEBUG)

# This is the worker process top-level loop, which just logs ten events with
# random intervening delays before terminating.
# The print messages are just so you know it's doing something!
def worker_process(queue, configurer):
    configurer(queue)
    name = multiprocessing.current_process().name
    print('Worker started: %s' % name)
    for i in range(10):
        time.sleep(random())
        logger = logging.getLogger(choice(LOGGERS))
        level = choice(LEVELS)
        message = choice(MESSAGES)
        logger.log(level, message)
    print('Worker finished: %s' % name)

# Here's where the demo gets orchestrated. Create the queue, create and start
# the listener, create ten workers and start them, wait for them to finish,
# then send a None to the queue to tell the listener to finish.
def main():
    queue = multiprocessing.Queue(-1)
    listener = multiprocessing.Process(target=listener_process,
                                       args=(queue, listener_configurer))
    listener.start()
    workers = []
    for i in range(10):
        worker = multiprocessing.Process(target=worker_process,
                                         args=(queue, worker_configurer))
        workers.append(worker)
        worker.start()
    for w in workers:
        w.join()
    queue.put_nowait(None)
    listener.join()

if __name__ == '__main__':
    main()

Вариант вышеприведённого скрипта сохраняет ведение журнала в основном процессе, в отдельном потоке:

import logging
import logging.config
import logging.handlers
from multiprocessing import Process, Queue
import random
import threading
import time

def logger_thread(q):
    while True:
        record = q.get()
        if record is None:
            break
        logger = logging.getLogger(record.name)
        logger.handle(record)


def worker_process(q):
    qh = logging.handlers.QueueHandler(q)
    root = logging.getLogger()
    root.setLevel(logging.DEBUG)
    root.addHandler(qh)
    levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR,
              logging.CRITICAL]
    loggers = ['foo', 'foo.bar', 'foo.bar.baz',
               'spam', 'spam.ham', 'spam.ham.eggs']
    for i in range(100):
        lvl = random.choice(levels)
        logger = logging.getLogger(random.choice(loggers))
        logger.log(lvl, 'Message no. %d', i)

if __name__ == '__main__':
    q = Queue()
    d = {
        'version': 1,
        'formatters': {
            'detailed': {
                'class': 'logging.Formatter',
                'format': '%(asctime)s %(name)-15s %(levelname)-8s %(processName)-10s %(message)s'
            }
        },
        'handlers': {
            'console': {
                'class': 'logging.StreamHandler',
                'level': 'INFO',
            },
            'file': {
                'class': 'logging.FileHandler',
                'filename': 'mplog.log',
                'mode': 'w',
                'formatter': 'detailed',
            },
            'foofile': {
                'class': 'logging.FileHandler',
                'filename': 'mplog-foo.log',
                'mode': 'w',
                'formatter': 'detailed',
            },
            'errors': {
                'class': 'logging.FileHandler',
                'filename': 'mplog-errors.log',
                'mode': 'w',
                'level': 'ERROR',
                'formatter': 'detailed',
            },
        },
        'loggers': {
            'foo': {
                'handlers': ['foofile']
            }
        },
        'root': {
            'level': 'DEBUG',
            'handlers': ['console', 'file', 'errors']
        },
    }
    workers = []
    for i in range(5):
        wp = Process(target=worker_process, name='worker %d' % (i + 1), args=(q,))
        workers.append(wp)
        wp.start()
    logging.config.dictConfig(d)
    lp = threading.Thread(target=logger_thread, args=(q,))
    lp.start()
    # At this point, the main process could do some useful work of its own
    # Once it's done that, it can wait for the workers to terminate...
    for wp in workers:
        wp.join()
    # And now tell the logging thread to finish up, too
    q.put(None)
    lp.join()

Этот вариант демонстрирует, как можно, например, применять конфигурацию для конкретных логгеров — например, логгер foo имеет специальный обработчик, который сохраняет все события подсистемы foo в файл mplog-foo.log. Это будет использоваться механизмом ведения журнала в основном процессе (даже если события ведения журнала генерируются в рабочих процессах), чтобы направить сообщения в соответствующие места назначения.

Использование concurrent.futures.ProcessPoolExecutor

Если вы хотите использовать concurrent.futures.ProcessPoolExecutor для запуска ваших рабочих процессов, вам нужно создать очередь немного иначе. Вместо

queue = multiprocessing.Queue(-1)

вы должны использовать

queue = multiprocessing.Manager().Queue(-1)  # also works with the examples above

и затем вы можете заменить создание рабочих процессов из этого:

workers = []
for i in range(10):
    worker = multiprocessing.Process(target=worker_process,
                                     args=(queue, worker_configurer))
    workers.append(worker)
    worker.start()
for w in workers:
    w.join()

на это (не забывая сначала импортировать concurrent.futures):

with concurrent.futures.ProcessPoolExecutor(max_workers=10) as executor:
    for i in range(10):
        executor.submit(worker_process, queue, worker_configurer)

Развёртывание веб-приложений с помощью Gunicorn и uWSGI

При развёртывании веб-приложений с помощью Gunicorn или uWSGI (или аналогичного), создаются несколько рабочих процессов для обработки запросов клиентов. В таких средах избегайте непосредственного создания обработчиков на основе файлов в вашем веб-приложении. Вместо этого используйте SocketHandler для ведения журнала веб-приложения в слушателе в отдельном процессе. Это можно настроить с помощью инструмента управления процессами, такого как Supervisor — см. Запуск слушателя сокета ведения журнала в продакшене для получения более подробной информации.

Использование вращения файлов

Иногда вы хотите позволить файлу журнала вырасти до определенного размера, а затем открыть новый файл и вести в него журнал. Вы можете сохранить определенное количество таких файлов, а когда создано столько файлов, повернуть файлы, чтобы и количество файлов, и размер файлов оставались ограниченными. Для этой модели использования пакет ведения журнала предоставляет RotatingFileHandler:

import glob
import logging
import logging.handlers

LOG_FILENAME = 'logging_rotatingfile_example.out'

# Set up a specific logger with our desired output level
my_logger = logging.getLogger('MyLogger')
my_logger.setLevel(logging.DEBUG)

# Add the log message handler to the logger
handler = logging.handlers.RotatingFileHandler(
              LOG_FILENAME, maxBytes=20, backupCount=5)

my_logger.addHandler(handler)

# Log some messages
for i in range(20):
    my_logger.debug('i = %d' % i)

# See what files are created
logfiles = glob.glob('%s*' % LOG_FILENAME)

for filename in logfiles:
    print(filename)

Результат должен быть 6 отдельных файлов, каждый из которых содержит часть истории журнала приложения:

logging_rotatingfile_example.out
logging_rotatingfile_example.out.1
logging_rotatingfile_example.out.2
logging_rotatingfile_example.out.3
logging_rotatingfile_example.out.4
logging_rotatingfile_example.out.5

Самый текущий файл всегда logging_rotatingfile_example.out, и каждый раз, когда он достигает лимита размера, он переименовывается с суффиксом .1. Каждый из существующих резервных файлов переименовывается с увеличением суффикса (.1 становится .2, и т. д.), и файл .6 удаляется.

Очевидно, что в этом примере длина журнала установлена слишком маленькой в качестве крайнего примера. Вы захотите установить maxBytes на соответствующее значение.

Использование альтернативных стилей форматирования

Когда ведение журнала было добавлено в стандартную библиотеку Python, единственный способ форматирования сообщений с переменным содержимым был использование метода %-форматирования. С тех пор Python обзавёлся двумя новыми подходами к форматированию: string.Template (добавлен в Python 2.4) и str.format() (добавлен в Python 2.6).

Ведение журнала (начиная с версии 3.2) обеспечивает улучшенную поддержку этих двух дополнительных стилей форматирования. Класс Formatter был расширен, чтобы принимать дополнительный необязательный ключевой параметр, названный style. По умолчанию он равен '%', но другими возможными значениями являются '{' и '$', которые соответствуют другим двум стилям форматирования. Совместимость со старыми версиями сохраняется по умолчанию (как можно ожидать), но явно задавая параметр style, вы получаете возможность указать строки форматирования, которые работают с str.format() или string.Template. Вот пример сеанса консоли, демонстрирующего возможности:

>>> import logging
>>> root = logging.getLogger()
>>> root.setLevel(logging.DEBUG)
>>> handler = logging.StreamHandler()
>>> bf = logging.Formatter('{asctime} {name} {levelname:8s} {message}',
...                        style='{')
>>> handler.setFormatter(bf)
>>> root.addHandler(handler)
>>> logger = logging.getLogger('foo.bar')
>>> logger.debug('This is a DEBUG message')
2010-10-28 15:11:55,341 foo.bar DEBUG    This is a DEBUG message
>>> logger.critical('This is a CRITICAL message')
2010-10-28 15:12:11,526 foo.bar CRITICAL This is a CRITICAL message
>>> df = logging.Formatter('$asctime $name ${levelname} $message',
...                        style='$')
>>> handler.setFormatter(df)
>>> logger.debug('This is a DEBUG message')
2010-10-28 15:13:06,924 foo.bar DEBUG This is a DEBUG message
>>> logger.critical('This is a CRITICAL message')
2010-10-28 15:13:11,494 foo.bar CRITICAL This is a CRITICAL message
>>>

Обратите внимание, что форматирование сообщений журнала для окончательного вывода в журналы полностью независимо от того, как строится отдельное сообщение журнала. Оно всё ещё может использовать %-форматирование, как показано здесь:

>>> logger.error('This is an%s %s %s', 'other,', 'ERROR,', 'message')
2010-10-28 15:19:29,833 foo.bar ERROR This is another, ERROR, message
>>>

Вызовы ведения журнала (logger.debug(), logger.info() и т. д.) принимают только позиционные параметры для самого сообщения журнала, а ключевые параметры используются только для определения параметров обработки фактического вызова ведения журнала (например, ключевой параметр exc_info для указания, что информация о трассировке должна быть записана в журнал, или ключевой параметр extra для указания дополнительной контекстной информации, которая должна быть добавлена в журнал). Поэтому вы не можете напрямую использовать вызовы ведения журнала с помощью синтаксиса str.format() или string.Template, потому что внутренне пакет ведения журнала использует %-форматирование для объединения строки форматирования и переменных аргументов. Изменения этого при сохранении обратной совместимости было бы невозможно, так как все вызовы ведения журнала, существующие в существующем коде, будут использовать %-строки форматирования.

Однако существует способ использовать форматирование {} и $ для создания отдельных сообщений журнала. Помните, что для сообщения вы можете использовать произвольный объект в качестве строки форматирования сообщения, и пакет ведения журнала вызовет str() для этого объекта, чтобы получить фактическую строку форматирования. Рассмотрим следующие два класса:

class BraceMessage:
    def __init__(self, fmt, /, *args, **kwargs):
        self.fmt = fmt
        self.args = args
        self.kwargs = kwargs

    def __str__(self):
        return self.fmt.format(*self.args, **self.kwargs)

class DollarMessage:
    def __init__(self, fmt, /, **kwargs):
        self.fmt = fmt
        self.kwargs = kwargs

    def __str__(self):
        from string import Template
        return Template(self.fmt).substitute(**self.kwargs)

Любой из них может использоваться вместо строки форматирования, чтобы разрешить форматирование {} или $ для построения фактической части «сообщения», которая появляется в отформатированном выводе журнала вместо «%(message)s» или «{message}» или «$message». Неудобно использовать имена классов каждый раз, когда нужно что-то записать в журнал, но это вполне приемлемо, если вы используете псевдоним, такой как __ (два нижних подчёркивания — не путать с _, одним нижним подчёркиванием, используемым как синоним/псевдоним для gettext.gettext() или его собратьев).

Вышеперечисленные классы не включены в Python, хотя их достаточно легко скопировать и вставить в свой собственный код. Их можно использовать следующим образом (предполагая, что они объявлены в модуле с именем wherever):

>>> from wherever import BraceMessage as __
>>> print(__('Message with {0} {name}', 2, name='placeholders'))
Message with 2 placeholders
>>> class Point: pass
...
>>> p = Point()
>>> p.x = 0.5
>>> p.y = 0.5
>>> print(__('Message with coordinates: ({point.x:.2f}, {point.y:.2f})',
...       point=p))
Message with coordinates: (0.50, 0.50)
>>> from wherever import DollarMessage as __
>>> print(__('Message with $num $what', num=2, what='placeholders'))
Message with 2 placeholders
>>>

Хотя в примерах выше используется print(), чтобы показать, как работает форматирование, вы, конечно же, будете использовать logger.debug() или аналогичное для фактического ведения журнала с помощью этого подхода.

Следует отметить, что вы не платите значительной штраф за производительность с этим подходом: фактическое форматирование происходит не тогда, когда вы делаете вызов ведения журнала, а тогда (и только если) записанное сообщение фактически должно быть выведено в журнал обработчиком. Единственное немного необычное, что может вас запутать, заключается в том, что скобки окружают строку форматирования и аргументы, а не только строку форматирования. Это потому, что обозначение __ — это просто синтаксический сахар для вызова конструктора одного из XXXMessage классов.

Если вы предпочитаете, вы можете использовать LoggerAdapter для достижения аналогичного эффекта, как показано в следующем примере:

import logging

class Message:
    def __init__(self, fmt, args):
        self.fmt = fmt
        self.args = args

    def __str__(self):
        return self.fmt.format(*self.args)

class StyleAdapter(logging.LoggerAdapter):
    def __init__(self, logger, extra=None):
        super().__init__(logger, extra or {})

    def log(self, level, msg, /, *args, **kwargs):
        if self.isEnabledFor(level):
            msg, kwargs = self.process(msg, kwargs)
            self.logger._log(level, Message(msg, args), (), **kwargs)

logger = StyleAdapter(logging.getLogger(__name__))

def main():
    logger.debug('Hello, {}', 'world!')

if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)
    main()

Вышеуказанный скрипт должен записать сообщение Hello, world! при запуске с Python 3.2 или более поздней версии.

Настройка LogRecord

Каждый журнальный запрос представлен экземпляром LogRecord. Когда событие регистрируется и не отфильтровывается уровнем журнала, создаётся экземпляр LogRecord, заполненный информацией о событии, а затем передаётся обработчикам этого журнала (и его предкам, вплоть до журнала, где дальнейшее распространение по иерархии отключено). До Python 3.2 это создание осуществлялось только в двух местах:

  • Logger.makeRecord(), который вызывается в нормальном процессе регистрации события. Это вызывало LogRecord напрямую для создания экземпляра.
  • makeLogRecord(), который вызывается со словарем, содержащим атрибуты, которые необходимо добавить в LogRecord. Обычно это вызывается, когда получен подходящий словарь через сеть (например, в формате pickle через SocketHandler, или в формате JSON через HTTPHandler).

Это обычно означало, что если вам нужно сделать что-то особенное с LogRecord, вам нужно было сделать одно из следующего.

  • Создайте свой собственный подкласс Logger, который переопределяет Logger.makeRecord(), и установите его с помощью setLoggerClass() до создания любых журналов, которые вас интересуют.
  • Добавьте Filter в журнал или обработчик, который выполняет необходимое специальное преобразование, когда вызывается метод filter().

Первый подход был бы немного неудобным в сценарии, где (скажем) несколько разных библиотек хотели бы делать разные вещи. Каждая из них пыталась установить свой собственный подкласс Logger, и тот, кто сделал это последним, победит.

Второй подход работает достаточно хорошо во многих случаях, но не позволяет, например, использовать специализированный подкласс LogRecord. Разработчики библиотек могут установить подходящий фильтр на свои журналы, но им нужно будет помнить об этом каждый раз, когда они вводят новый журнал (что они делают, просто добавляя новые пакеты или модули и делая

logger = logging.getLogger(__name__)

на уровне модуля). Это, вероятно, слишком много вещей, о которых нужно думать. Разработчики также могли бы добавить фильтр в NullHandler, прикреплённый к их главному журналу, но это не будет вызываться, если разработчик приложения подключит обработчик к журналу библиотеки нижнего уровня — поэтому вывод этого обработчика не будет отражать намерений разработчика библиотеки.

В Python 3.2 и более поздних версиях создание LogRecord осуществляется с помощью фабрики, которую вы можете указать. Фабрика — это просто вызываемый объект, который можно установить с помощью setLogRecordFactory() и получить с помощью getLogRecordFactory(). Фабрика вызывается с тем же сигнатуром, что и конструктор LogRecord, так как LogRecord является значением по умолчанию для фабрики.

Этот подход позволяет пользовательской фабрике контролировать все аспекты создания LogRecord. Например, вы можете вернуть подкласс или просто добавить некоторые дополнительные атрибуты к записи после создания, используя шаблон, похожий на этот:

old_factory = logging.getLogRecordFactory()

def record_factory(*args, **kwargs):
    record = old_factory(*args, **kwargs)
    record.custom_attribute = 0xdecafbad
    return record

logging.setLogRecordFactory(record_factory)

Этот шаблон позволяет различным библиотекам объединять фабрики, и, пока они не перезаписывают атрибуты друг друга или случайно не перезаписывают предоставленные стандартные атрибуты, никаких неожиданностей быть не должно. Однако следует иметь в виду, что каждый элемент в цепочке добавляет время выполнения к всем операциям ведения журнала, и этот метод следует использовать только в том случае, если использование Filter не даёт желаемого результата.

Наследование от QueueHandler — пример с ZeroMQ

Вы можете использовать подкласс QueueHandler, чтобы отправлять сообщения в другие типы очередей, например, в сокет ZeroMQ «publish». В примере ниже сокет создаётся отдельно и передаётся обработчику (в качестве его «очереди»):

import zmq   # using pyzmq, the Python binding for ZeroMQ
import json  # for serializing records portably

ctx = zmq.Context()
sock = zmq.Socket(ctx, zmq.PUB)  # or zmq.PUSH, or other suitable value
sock.bind('tcp://*:5556')        # or wherever

class ZeroMQSocketHandler(QueueHandler):
    def enqueue(self, record):
        self.queue.send_json(record.__dict__)


handler = ZeroMQSocketHandler(sock)

Конечно, есть и другие способы организации этого, например, передать данные, необходимые обработчику для создания сокета:

class ZeroMQSocketHandler(QueueHandler):
    def __init__(self, uri, socktype=zmq.PUB, ctx=None):
        self.ctx = ctx or zmq.Context()
        socket = zmq.Socket(self.ctx, socktype)
        socket.bind(uri)
        super().__init__(socket)

    def enqueue(self, record):
        self.queue.send_json(record.__dict__)

    def close(self):
        self.queue.close()

Наследование от QueueListener — пример с ZeroMQ

Вы также можете наследовать от QueueListener, чтобы получать сообщения из других типов очередей, например, из сокета ZeroMQ «subscribe». Вот пример:

class ZeroMQSocketListener(QueueListener):
    def __init__(self, uri, /, *handlers, **kwargs):
        self.ctx = kwargs.get('ctx') or zmq.Context()
        socket = zmq.Socket(self.ctx, zmq.SUB)
        socket.setsockopt_string(zmq.SUBSCRIBE, '')  # subscribe to everything
        socket.connect(uri)
        super().__init__(socket, *handlers, **kwargs)

    def dequeue(self):
        msg = self.queue.recv_json()
        return logging.makeLogRecord(msg)

См. также

Module logging

Справочник по модулю ведения журнала.

Module logging.config

API конфигурации модуля ведения журнала.

Module logging.handlers

Полезные обработчики, включенные в модуль ведения журнала.

Базовый учебник по ведению журнала

Расширенный учебник по ведению журнала

Пример конфигурации на основе словаря

Ниже приведён пример словаря конфигурации ведения журнала — он взят из документации проекта Django. Этот словарь передаётся в dictConfig(), чтобы ввести конфигурацию в действие:

LOGGING = {
    'version': 1,
    'disable_existing_loggers': True,
    'formatters': {
        'verbose': {
            'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
        },
        'simple': {
            'format': '%(levelname)s %(message)s'
        },
    },
    'filters': {
        'special': {
            '()': 'project.logging.SpecialFilter',
            'foo': 'bar',
        }
    },
    'handlers': {
        'null': {
            'level':'DEBUG',
            'class':'django.utils.log.NullHandler',
        },
        'console':{
            'level':'DEBUG',
            'class':'logging.StreamHandler',
            'formatter': 'simple'
        },
        'mail_admins': {
            'level': 'ERROR',
            'class': 'django.utils.log.AdminEmailHandler',
            'filters': ['special']
        }
    },
    'loggers': {
        'django': {
            'handlers':['null'],
            'propagate': True,
            'level':'INFO',
        },
        'django.request': {
            'handlers': ['mail_admins'],
            'level': 'ERROR',
            'propagate': False,
        },
        'myproject.custom': {
            'handlers': ['console', 'mail_admins'],
            'level': 'INFO',
            'filters': ['special']
        }
    }
}

Дополнительную информацию об этой конфигурации можно найти в соответствующем разделе документации Django.

Использование rotator и namer для настройки обработки вращения логов

Пример того, как определить namer и rotator, представлен в следующем исполняемом скрипте, который демонстрирует сжатие файла журнала с помощью gzip:

import gzip
import logging
import logging.handlers
import os
import shutil

def namer(name):
    return name + ".gz"

def rotator(source, dest):
    with open(source, 'rb') as f_in:
        with gzip.open(dest, 'wb') as f_out:
            shutil.copyfileobj(f_in, f_out)
    os.remove(source)


rh = logging.handlers.RotatingFileHandler('rotated.log', maxBytes=128, backupCount=5)
rh.rotator = rotator
rh.namer = namer

root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(rh)
f = logging.Formatter('%(asctime)s %(message)s')
rh.setFormatter(f)
for i in range(1000):
    root.info(f'Message no. {i + 1}')

После выполнения этого скрипта вы увидите шесть новых файлов, пять из которых сжаты:

$ ls rotated.log*
rotated.log       rotated.log.2.gz  rotated.log.4.gz
rotated.log.1.gz  rotated.log.3.gz  rotated.log.5.gz
$ zcat rotated.log.1.gz
2023-01-20 02:28:17,767 Message no. 996
2023-01-20 02:28:17,767 Message no. 997
2023-01-20 02:28:17,767 Message no. 998

Более сложный пример с многопроцессорной обработкой

Следующий рабочий пример демонстрирует, как ведение журнала можно использовать с многопроцессорной обработкой, используя файлы конфигурации. Конфигурации довольно простые, но служат для иллюстрации того, как можно реализовать более сложные в реальном сценарии многопроцессорной обработки.

В примере основной процесс запускает процесс-слушатель и несколько рабочих процессов. Каждый из основного процесса, процесса-слушателя и рабочих процессов имеет три отдельные конфигурации (все рабочие процессы используют одну и ту же конфигурацию). Мы можем видеть ведение журнала в основном процессе, как рабочие процессы записывают в QueueHandler, как слушатель реализует QueueListener и более сложную конфигурацию ведения журнала и организует рассылку событий, полученных через очередь, обработчикам, указанным в конфигурации. Обратите внимание, что эти конфигурации являются чисто иллюстративными, но вы должны быть в состоянии адаптировать этот пример к своему сценарию.

Вот скрипт — документация и комментарии, надеюсь, объясняют, как он работает:

import logging
import logging.config
import logging.handlers
from multiprocessing import Process, Queue, Event, current_process
import os
import random
import time

class MyHandler:
    """
    A simple handler for logging events. It runs in the listener process and
    dispatches events to loggers based on the name in the received record,
    which then get dispatched, by the logging system, to the handlers
    configured for those loggers.
    """

    def handle(self, record):
        if record.name == "root":
            logger = logging.getLogger()
        else:
            logger = logging.getLogger(record.name)

        if logger.isEnabledFor(record.levelno):
            # The process name is transformed just to show that it's the listener
            # doing the logging to files and console
            record.processName = '%s (for %s)' % (current_process().name, record.processName)
            logger.handle(record)

def listener_process(q, stop_event, config):
    """
    This could be done in the main process, but is just done in a separate
    process for illustrative purposes.

    This initialises logging according to the specified configuration,
    starts the listener and waits for the main process to signal completion
    via the event. The listener is then stopped, and the process exits.
    """
    logging.config.dictConfig(config)
    listener = logging.handlers.QueueListener(q, MyHandler())
    listener.start()
    if os.name == 'posix':
        # On POSIX, the setup logger will have been configured in the
        # parent process, but should have been disabled following the
        # dictConfig call.
        # On Windows, since fork isn't used, the setup logger won't
        # exist in the child, so it would be created and the message
        # would appear - hence the "if posix" clause.
        logger = logging.getLogger('setup')
        logger.critical('Should not appear, because of disabled logger ...')
    stop_event.wait()
    listener.stop()

def worker_process(config):
    """
    A number of these are spawned for the purpose of illustration. In
    practice, they could be a heterogeneous bunch of processes rather than
    ones which are identical to each other.

    This initialises logging according to the specified configuration,
    and logs a hundred messages with random levels to randomly selected
    loggers.

    A small sleep is added to allow other processes a chance to run. This
    is not strictly needed, but it mixes the output from the different
    processes a bit more than if it's left out.
    """
    logging.config.dictConfig(config)
    levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR,
              logging.CRITICAL]
    loggers = ['foo', 'foo.bar', 'foo.bar.baz',
               'spam', 'spam.ham', 'spam.ham.eggs']
    if os.name == 'posix':
        # On POSIX, the setup logger will have been configured in the
        # parent process, but should have been disabled following the
        # dictConfig call.
        # On Windows, since fork isn't used, the setup logger won't
        # exist in the child, so it would be created and the message
        # would appear - hence the "if posix" clause.
        logger = logging.getLogger('setup')
        logger.critical('Should not appear, because of disabled logger ...')
    for i in range(100):
        lvl = random.choice(levels)
        logger = logging.getLogger(random.choice(loggers))
        logger.log(lvl, 'Message no. %d', i)
        time.sleep(0.01)

def main():
    q = Queue()
    # The main process gets a simple configuration which prints to the console.
    config_initial = {
        'version': 1,
        'handlers': {
            'console': {
                'class': 'logging.StreamHandler',
                'level': 'INFO'
            }
        },
        'root': {
            'handlers': ['console'],
            'level': 'DEBUG'
        }
    }
    # The worker process configuration is just a QueueHandler attached to the
    # root logger, which allows all messages to be sent to the queue.
    # We disable existing loggers to disable the "setup" logger used in the
    # parent process. This is needed on POSIX because the logger will
    # be there in the child following a fork().
    config_worker = {
        'version': 1,
        'disable_existing_loggers': True,
        'handlers': {
            'queue': {
                'class': 'logging.handlers.QueueHandler',
                'queue': q
            }
        },
        'root': {
            'handlers': ['queue'],
            'level': 'DEBUG'
        }
    }
    # The listener process configuration shows that the full flexibility of
    # logging configuration is available to dispatch events to handlers however
    # you want.
    # We disable existing loggers to disable the "setup" logger used in the
    # parent process. This is needed on POSIX because the logger will
    # be there in the child following a fork().
    config_listener = {
        'version': 1,
        'disable_existing_loggers': True,
        'formatters': {
            'detailed': {
                'class': 'logging.Formatter',
                'format': '%(asctime)s %(name)-15s %(levelname)-8s %(processName)-10s %(message)s'
            },
            'simple': {
                'class': 'logging.Formatter',
                'format': '%(name)-15s %(levelname)-8s %(processName)-10s %(message)s'
            }
        },
        'handlers': {
            'console': {
                'class': 'logging.StreamHandler',
                'formatter': 'simple',
                'level': 'INFO'
            },
            'file': {
                'class': 'logging.FileHandler',
                'filename': 'mplog.log',
                'mode': 'w',
                'formatter': 'detailed'
            },
            'foofile': {
                'class': 'logging.FileHandler',
                'filename': 'mplog-foo.log',
                'mode': 'w',
                'formatter': 'detailed'
            },
            'errors': {
                'class': 'logging.FileHandler',
                'filename': 'mplog-errors.log',
                'mode': 'w',
                'formatter': 'detailed',
                'level': 'ERROR'
            }
        },
        'loggers': {
            'foo': {
                'handlers': ['foofile']
            }
        },
        'root': {
            'handlers': ['console', 'file', 'errors'],
            'level': 'DEBUG'
        }
    }
    # Log some initial events, just to show that logging in the parent works
    # normally.
    logging.config.dictConfig(config_initial)
    logger = logging.getLogger('setup')
    logger.info('About to create workers ...')
    workers = []
    for i in range(5):
        wp = Process(target=worker_process, name='worker %d' % (i + 1),
                     args=(config_worker,))
        workers.append(wp)
        wp.start()
        logger.info('Started worker: %s', wp.name)
    logger.info('About to create listener ...')
    stop_event = Event()
    lp = Process(target=listener_process, name='listener',
                 args=(q, stop_event, config_listener))
    lp.start()
    logger.info('Started listener')
    # We now hang around for the workers to finish their work.
    for wp in workers:
        wp.join()
    # Workers all done, listening can now stop.
    # Logging in the parent still works normally.
    logger.info('Telling listener to stop ...')
    stop_event.set()
    lp.join()
    logger.info('All done.')

if __name__ == '__main__':
    main()

Вставка BOM в сообщения, отправленные SysLogHandler

RFC 5424 требует, чтобы сообщение Unicode отправлялось демону syslog в виде набора байтов со следующей структурой: необязательная чисто-ASCII составляющая, за которой следует метка порядка байтов UTF-8 (BOM), за которой следует Unicode, закодированный с использованием UTF-8. (См. соответствующий раздел спецификации.)

В Python 3.1 в SysLogHandler был добавлен код для вставки BOM в сообщение, но, к сожалению, он был реализован неправильно, и BOM появлялся в начале сообщения, что не позволяло никакой чисто-ASCII составляющей появиться перед ним.

Поскольку это поведение некорректно, код неправильной вставки BOM удаляется из Python 3.2.4 и более поздних версий. Однако он не заменяется, и если вы хотите создавать совместимые с RFC 5424 сообщения, которые включают BOM, необязательную чисто-ASCII последовательность перед ним и произвольный Unicode после неё, закодированные с использованием UTF-8, выполните следующие действия:

  1. Прикрепите экземпляр Formatter к экземпляру SysLogHandler с таким форматом строки:

    'ASCII section\ufeffUnicode section'
    

    Код Unicode-символа U+FEFF, при кодировании с использованием UTF-8, будет закодирован как метка порядка байтов UTF-8 — байтовая строка b'\xef\xbb\xbf'.

  2. Замените ASCII-часть любыми маркерами, но убедитесь, что данные, появляющиеся там после замены, всегда будут ASCII (так они останутся неизменными после кодирования UTF-8).
  3. Замените Unicode-часть любыми маркерами; если данные, появившиеся там после замены, содержат символы за пределами ASCII, это нормально — они будут закодированы с использованием UTF-8.

Отформатированное сообщение будет закодировано с использованием кодирования UTF-8 в SysLogHandler. Если вы следуете этим правилам, вы должны быть в состоянии создать совместимые с RFC 5424 сообщения. Если нет, ведение журнала может не жаловаться, но ваши сообщения не будут соответствовать RFC 5424, и ваш демон syslog может пожаловаться.

Реализация структурированного ведения журнала

Хотя большинство сообщений ведения журнала предназначены для чтения людьми и, следовательно, не легко анализируются машиной, могут возникнуть обстоятельства, когда вы захотите выводить сообщения в структурированном формате, который может быть проанализирован программой (без необходимости сложных регулярных выражений для анализа сообщения журнала). Это легко достигается с помощью пакета ведения журнала. Есть несколько способов добиться этого, но следующий способ использует JSON для сериализации события в формате, анализируемом машиной:

import json
import logging

class StructuredMessage:
    def __init__(self, message, /, **kwargs):
        self.message = message
        self.kwargs = kwargs

    def __str__(self):
        return '%s >>> %s' % (self.message, json.dumps(self.kwargs))

_ = StructuredMessage   # optional, to improve readability

logging.basicConfig(level=logging.INFO, format='%(message)s')
logging.info(_('message 1', foo='bar', bar='baz', num=123, fnum=123.456))

Если вышеприведенный скрипт будет запущен, он напечатает:

message 1 >>> {"fnum": 123.456, "num": 123, "bar": "baz", "foo": "bar"}

Обратите внимание, что порядок элементов может отличаться в зависимости от используемой версии Python.

Если вам нужна более специализированная обработка, вы можете использовать пользовательский кодировщик JSON, как в следующем полном примере:

import json
import logging


class Encoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, set):
            return tuple(o)
        elif isinstance(o, str):
            return o.encode('unicode_escape').decode('ascii')
        return super().default(o)

class StructuredMessage:
    def __init__(self, message, /, **kwargs):
        self.message = message
        self.kwargs = kwargs

    def __str__(self):
        s = Encoder().encode(self.kwargs)
        return '%s >>> %s' % (self.message, s)

_ = StructuredMessage   # optional, to improve readability

def main():
    logging.basicConfig(level=logging.INFO, format='%(message)s')
    logging.info(_('message 1', set_value={1, 2, 3}, snowman='\u2603'))

if __name__ == '__main__':
    main()

Когда вышеприведённый скрипт будет запущен, он напечатает:

message 1 >>> {"snowman": "\u2603", "set_value": [1, 2, 3]}

Обратите внимание, что порядок элементов может отличаться в зависимости от используемой версии Python.

Настройка обработчиков с помощью dictConfig()

Иногда требуется настроить обработчики логов определенным образом, и если вы используете dictConfig(), вы можете сделать это без наследования. Например, предположим, что вы хотите установить владельца файла логов. В POSIX это легко сделать с помощью shutil.chown(), но обработчики файлов в стандартной библиотеке не поддерживают это встроенно. Вы можете настроить создание обработчика с помощью обычной функции, например:

def owned_file_handler(filename, mode='a', encoding=None, owner=None):
    if owner:
        if not os.path.exists(filename):
            open(filename, 'a').close()
        shutil.chown(filename, *owner)
    return logging.FileHandler(filename, mode, encoding)

Затем вы можете указать в конфигурации логов, передаваемой dictConfig(), что обработчик логов должен создаваться вызовом этой функции:

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'default': {
            'format': '%(asctime)s %(levelname)s %(name)s %(message)s'
        },
    },
    'handlers': {
        'file':{
            # The values below are popped from this dictionary and
            # used to create the handler, set the handler's level and
            # its formatter.
            '()': owned_file_handler,
            'level':'DEBUG',
            'formatter': 'default',
            # The values below are passed to the handler creator callable
            # as keyword arguments.
            'owner': ['pulse', 'pulse'],
            'filename': 'chowntest.log',
            'mode': 'w',
            'encoding': 'utf-8',
        },
    },
    'root': {
        'handlers': ['file'],
        'level': 'DEBUG',
    },
}

В этом примере я устанавливаю владельца с помощью пользователя и группы pulse, просто для иллюстрации. Объединив всё в рабочую скрипт, получим chowntest.py:

import logging, logging.config, os, shutil

def owned_file_handler(filename, mode='a', encoding=None, owner=None):
    if owner:
        if not os.path.exists(filename):
            open(filename, 'a').close()
        shutil.chown(filename, *owner)
    return logging.FileHandler(filename, mode, encoding)

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'default': {
            'format': '%(asctime)s %(levelname)s %(name)s %(message)s'
        },
    },
    'handlers': {
        'file':{
            # The values below are popped from this dictionary and
            # used to create the handler, set the handler's level and
            # its formatter.
            '()': owned_file_handler,
            'level':'DEBUG',
            'formatter': 'default',
            # The values below are passed to the handler creator callable
            # as keyword arguments.
            'owner': ['pulse', 'pulse'],
            'filename': 'chowntest.log',
            'mode': 'w',
            'encoding': 'utf-8',
        },
    },
    'root': {
        'handlers': ['file'],
        'level': 'DEBUG',
    },
}

logging.config.dictConfig(LOGGING)
logger = logging.getLogger('mylogger')
logger.debug('A debug message')

Для запуска вам, вероятно, потребуется работать как root:

$ sudo python3.3 chowntest.py
$ cat chowntest.log
2013-11-05 09:34:51,128 DEBUG mylogger A debug message
$ ls -l chowntest.log
-rw-r--r-- 1 pulse pulse 55 2013-11-05 09:34 chowntest.log

Обратите внимание, что этот пример использует Python 3.3, так как именно там появляется shutil.chown(). Этот подход должен работать с любой версией Python, поддерживающей dictConfig() — а именно, Python 2.7, 3.2 или более поздние версии. В версиях до 3.3 вам нужно будет реализовать фактическое изменение владения, используя, например, os.chown().

На практике функция создания обработчика может находиться в модуле утилиты где-то в вашем проекте. Вместо строки в конфигурации:

'()': owned_file_handler,

можно использовать, например:

'()': 'ext://project.util.owned_file_handler',

где project.util может быть заменено фактическим именем пакета, где находится функция. В вышеприведённой рабочей скрипте использование 'ext://__main__.owned_file_handler' должно сработать. Здесь фактическая вызываемая функция разрешается dictConfig() из спецификации ext://.

Этот пример, надеюсь, также указывает путь к тому, как вы можете реализовать другие типы изменений файлов, например, установку определённых битов разрешений POSIX, таким же образом, используя os.chmod().

Конечно, этот подход также можно расширить на типы обработчиков, отличные от FileHandler — например, на один из обработчиков файлов с циклическим переходом или на другой тип обработчика.

Использование определённых стилей форматирования во всем приложении

В Python 3.2 у Formatter появился параметр ключевого слова style, который, хотя и по умолчанию имел значение % для обеспечения обратной совместимости, позволял указать { или $ для поддержки подходов к форматированию, поддерживаемых str.format() и string.Template. Обратите внимание, что это регулирует форматирование сообщений логов для окончательного вывода в логи и полностью ортогонально тому, как строится индивидуальное сообщение лога.

Вызовы логов (debug(), info() и т.д.) принимают только позиционные параметры для самого сообщения лога, а параметры ключевых слов используются только для определения параметров обработки вызова лога (например, параметр ключевого слова exc_info для указания того, что информация о трассировке должна быть записана в журнал, или параметр ключевого слова extra для указания дополнительной контекстной информации, которая должна быть добавлена в журнал). Поэтому вы не можете напрямую использовать вызовы логов с синтаксисом str.format() или string.Template, потому что внутренне пакет логов использует форматирование % для объединения строки формата и аргументов переменных. Изменение этого при сохранении обратной совместимости невозможно, так как все вызовы логов, которые существуют в существующем коде, будут использовать строки форматирования %.

Были предложения связать стили форматирования со специфическими логгерами, но этот подход также сталкивается с проблемами обратной совместимости, так как любой существующий код может использовать данное имя логгера и использовать форматирование %.

Для того чтобы логи работали с любыми сторонними библиотеками и вашим кодом, решения по форматированию должны приниматься на уровне отдельных вызовов логов. Это открывает несколько способов адаптации альтернативных стилей форматирования.

Использование фабрик LogRecord

В Python 3.2 вместе с изменениями Formatter, упомянутыми выше, пакет логов получил возможность позволить пользователям устанавливать свои собственные подклассы LogRecord с помощью функции setLogRecordFactory(). Вы можете использовать это для установки своего собственного подкласса LogRecord, который делает правильные вещи, переопределяя метод getMessage(). В базовой реализации этого метода происходит форматирование msg % args, и здесь вы можете заменить своё альтернативное форматирование; однако, следует быть осторожным, чтобы поддерживать все стили форматирования и разрешить форматирование % по умолчанию, чтобы обеспечить межсовместимость с другим кодом. Также следует позаботиться о вызове str(self.msg), как это делает базовая реализация.

Дополнительную информацию см. в документации по setLogRecordFactory() и LogRecord.

Использование пользовательских объектов сообщений

Есть другой, возможно, более простой способ использования форматирования {} и $ для создания индивидуальных сообщений логов. Вы помните (из Использование произвольных объектов в качестве сообщений), что при регистрации вы можете использовать произвольный объект в качестве строки формата, и что пакет логов вызовет str() на этом объекте, чтобы получить фактическую строку формата. Рассмотрим следующие два класса:

class BraceMessage:
    def __init__(self, fmt, /, *args, **kwargs):
        self.fmt = fmt
        self.args = args
        self.kwargs = kwargs

    def __str__(self):
        return self.fmt.format(*self.args, **self.kwargs)

class DollarMessage:
    def __init__(self, fmt, /, **kwargs):
        self.fmt = fmt
        self.kwargs = kwargs

    def __str__(self):
        from string import Template
        return Template(self.fmt).substitute(**self.kwargs)

Любой из них можно использовать вместо строки формата, чтобы разрешить форматирование {} или $ для построения фактической части «сообщения», которая появляется в отформатированном выводе лога вместо «%(message)s» или «{message}» или «$message». Если вы считаете, что использование имён классов немного громоздко всякий раз, когда вы хотите что-то записать в журнал, вы можете сделать его более удобоваримым, если используете псевдоним, такой как M или _ для сообщения (или, возможно, __, если вы используете _ для локализации).

Ниже приведены примеры этого подхода. Во-первых, форматирование с помощью str.format():

>>> __ = BraceMessage
>>> print(__('Message with {0} {1}', 2, 'placeholders'))
Message with 2 placeholders
>>> class Point: pass
...
>>> p = Point()
>>> p.x = 0.5
>>> p.y = 0.5
>>> print(__('Message with coordinates: ({point.x:.2f}, {point.y:.2f})', point=p))
Message with coordinates: (0.50, 0.50)

Во-вторых, форматирование с помощью string.Template:

>>> __ = DollarMessage
>>> print(__('Message with $num $what', num=2, what='placeholders'))
Message with 2 placeholders
>>>

Следует отметить, что с этим подходом вы не платите значительной цены за производительность: фактическое форматирование происходит не при выполнении вызова лога, а (и если) записанное сообщение фактически должно быть выведено в журнал обработчиком. Поэтому единственное немного необычное, что может вас запутать, заключается в том, что скобки ставятся вокруг строки формата и аргументов, а не только вокруг строки формата. Это потому, что обозначение __ — просто синтаксический сахар для вызова конструктора одного из классов XXXMessage.

Настройка фильтров с помощью dictConfig()

Вы можете настроить фильтры с помощью dictConfig(), хотя на первый взгляд это может быть не очевидно (отсюда и этот рецепт). Поскольку Filter — единственный класс фильтра, включённый в стандартную библиотеку, и он вряд ли удовлетворит многие требования (он существует только как базовый класс), вам обычно необходимо определить свой подкласс Filter с переопределённым методом filter(). Для этого укажите ключ () в словаре конфигурации для фильтра, указав вызываемый объект, который будет использоваться для создания фильтра (классом является наиболее очевидный вариант, но вы можете указать любой вызываемый объект, возвращающий экземпляр Filter). Вот полный пример:

import logging
import logging.config
import sys

class MyFilter(logging.Filter):
    def __init__(self, param=None):
        self.param = param

    def filter(self, record):
        if self.param is None:
            allow = True
        else:
            allow = self.param not in record.msg
        if allow:
            record.msg = 'changed: ' + record.msg
        return allow

LOGGING = {
    'version': 1,
    'filters': {
        'myfilter': {
            '()': MyFilter,
            'param': 'noshow',
        }
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'filters': ['myfilter']
        }
    },
    'root': {
        'level': 'DEBUG',
        'handlers': ['console']
    },
}

if __name__ == '__main__':
    logging.config.dictConfig(LOGGING)
    logging.debug('hello')
    logging.debug('hello - noshow')

Этот пример показывает, как вы можете передавать данные конфигурации вызываемому объекту, создающему экземпляр, в виде ключевых параметров. При выполнении вышеуказанного скрипта будет выведено:

changed: hello

что показывает, что фильтр работает как настроено.

Несколько дополнительных моментов:

  • Если вы не можете обратиться к вызываемому объекту напрямую в конфигурации (например, если он находится в другом модуле и его нельзя напрямую импортировать там, где находится словарь конфигурации), вы можете использовать форму ext://..., как описано в Доступ к внешним объектам. Например, вы могли бы использовать текст 'ext://__main__.MyFilter' вместо MyFilter в приведённом выше примере.
  • Помимо фильтров, этот метод также можно использовать для настройки пользовательских обработчиков и форматеров. Дополнительную информацию о том, как logging поддерживает использование пользовательских объектов в своей конфигурации, см. в Пользовательские объекты, а также в другом рецепте по книге рецептов Настройка обработчиков с помощью dictConfig() выше.

Настройка форматирования исключений

Возможны ситуации, когда вам нужно настроить форматирование исключений — например, предположим, что вы хотите ровно одну строку на каждый зарегистрированный запрос, даже когда присутствует информация об исключении. Вы можете сделать это с помощью пользовательского класса форматера, как показано в следующем примере:

import logging

class OneLineExceptionFormatter(logging.Formatter):
    def formatException(self, exc_info):
        """
        Format an exception so that it prints on a single line.
        """
        result = super().formatException(exc_info)
        return repr(result)  # or format into one line however you want to

    def format(self, record):
        s = super().format(record)
        if record.exc_text:
            s = s.replace('\n', '') + '|'
        return s

def configure_logging():
    fh = logging.FileHandler('output.txt', 'w')
    f = OneLineExceptionFormatter('%(asctime)s|%(levelname)s|%(message)s|',
                                  '%d/%m/%Y %H:%M:%S')
    fh.setFormatter(f)
    root = logging.getLogger()
    root.setLevel(logging.DEBUG)
    root.addHandler(fh)

def main():
    configure_logging()
    logging.info('Sample message')
    try:
        x = 1 / 0
    except ZeroDivisionError as e:
        logging.exception('ZeroDivisionError: %s', e)

if __name__ == '__main__':
    main()

При выполнении этого действия создается файл ровно с двумя строками:

28/01/2015 07:21:23|INFO|Sample message|
28/01/2015 07:21:23|ERROR|ZeroDivisionError: integer division or modulo by zero|'Traceback (most recent call last):\n  File "logtest7.py", line 30, in main\n    x = 1 / 0\nZeroDivisionError: integer division or modulo by zero'|

Хотя вышеизложенное простое, оно показывает, как можно настроить форматирование информации об исключении по своему усмотрению. Модуль traceback может быть полезен для более специализированных задач.

Проговаривание сообщений логирования

Возможны ситуации, когда желательно, чтобы сообщения логирования отображались в формате звуковой, а не визуальном. Это легко сделать, если в вашей системе доступна функция распознавания речи (TTS), даже если у неё нет привязки к Python. Большинство систем TTS имеют программу командной строки, которую можно запустить, и к ней можно обратиться из обработчика, используя subprocess. Предполагается, что программы командной строки TTS не будут ожидать взаимодействия с пользователем или длительного времени выполнения, и что частота сообщений логирования не будет слишком высокой, чтобы перегрузить пользователя сообщениями, и что приемлемо проговаривать сообщения по одному, а не одновременно. Приведённая ниже примерная реализация ожидает, пока одно сообщение не будет проговорено, прежде чем перейти к обработке следующего, что может заставить другие обработчики ожидать. Вот короткий пример, демонстрирующий подход, предполагающий, что пакет TTS espeak доступен:

import logging
import subprocess
import sys

class TTSHandler(logging.Handler):
    def emit(self, record):
        msg = self.format(record)
        # Speak slowly in a female English voice
        cmd = ['espeak', '-s150', '-ven+f3', msg]
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT)
        # wait for the program to finish
        p.communicate()

def configure_logging():
    h = TTSHandler()
    root = logging.getLogger()
    root.addHandler(h)
    # the default formatter just returns the message
    root.setLevel(logging.DEBUG)

def main():
    logging.info('Hello')
    logging.debug('Goodbye')

if __name__ == '__main__':
    configure_logging()
    sys.exit(main())

При выполнении этого скрипта должно звучать «Здравствуйте» и затем «До свидания» женским голосом.

Конечно, указанный выше подход можно адаптировать к другим системам TTS и даже к другим системам вообще, которые могут обрабатывать сообщения через внешние программы, запускаемые из командной строки.

Буферизация сообщений логирования и условный вывод

Возможны ситуации, когда вы хотите записывать сообщения в временную область и выводить их только при выполнении определённого условия. Например, вы можете начать логирование отладочных событий в функции, и если функция завершается без ошибок, вы не хотите засорять журнал собранной отладочной информацией, но если произошла ошибка, вы хотите вывести всю отладочную информацию, а также ошибку.

Вот пример, показывающий, как можно сделать это с помощью декоратора для функций, где вы хотите, чтобы логирование работало таким образом. Он использует logging.handlers.MemoryHandler, который позволяет буферизовать зарегистрированные события до возникновения определённого условия, после чего буферизованные события flushed — передаются другому обработчику (обработчику target ) для обработки. По умолчанию MemoryHandler очищается, когда его буфер заполняется или появляется событие, уровень которого не ниже заданного порога. Вы можете использовать этот рецепт с более специализированным подклассом MemoryHandler , если вы хотите настроить поведение очистки.

В примере скрипта есть простая функция foo, которая просто перебирает все уровни логирования, записывая в sys.stderr, о каком уровне она собирается записать, а затем фактически записывает сообщение на этом уровне. Вы можете передать параметр в foo, который, если он имеет значение true, будет записывать на уровнях ERROR и CRITICAL — в противном случае он записывает только на уровнях DEBUG, INFO и WARNING.

Сценарий просто организует декорацию foo с помощью декоратора, который выполнит требуемое условное логирование. Декоратор принимает логгер в качестве параметра и прикрепляет обработчик памяти на время вызова декорированной функции. Декоратор дополнительно может параметризоваться с помощью целевого обработчика, уровня, на котором должна произойти очистка, и объёма буфера (числа зарегистрированных записей). По умолчанию это StreamHandler, который записывает в sys.stderr, logging.ERROR и 100 соответственно.

Вот скрипт:

import logging
from logging.handlers import MemoryHandler
import sys

logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())

def log_if_errors(logger, target_handler=None, flush_level=None, capacity=None):
    if target_handler is None:
        target_handler = logging.StreamHandler()
    if flush_level is None:
        flush_level = logging.ERROR
    if capacity is None:
        capacity = 100
    handler = MemoryHandler(capacity, flushLevel=flush_level, target=target_handler)

    def decorator(fn):
        def wrapper(*args, **kwargs):
            logger.addHandler(handler)
            try:
                return fn(*args, **kwargs)
            except Exception:
                logger.exception('call failed')
                raise
            finally:
                super(MemoryHandler, handler).flush()
                logger.removeHandler(handler)
        return wrapper

    return decorator

def write_line(s):
    sys.stderr.write('%s\n' % s)

def foo(fail=False):
    write_line('about to log at DEBUG ...')
    logger.debug('Actually logged at DEBUG')
    write_line('about to log at INFO ...')
    logger.info('Actually logged at INFO')
    write_line('about to log at WARNING ...')
    logger.warning('Actually logged at WARNING')
    if fail:
        write_line('about to log at ERROR ...')
        logger.error('Actually logged at ERROR')
        write_line('about to log at CRITICAL ...')
        logger.critical('Actually logged at CRITICAL')
    return fail

decorated_foo = log_if_errors(logger)(foo)

if __name__ == '__main__':
    logger.setLevel(logging.DEBUG)
    write_line('Calling undecorated foo with False')
    assert not foo(False)
    write_line('Calling undecorated foo with True')
    assert foo(True)
    write_line('Calling decorated foo with False')
    assert not decorated_foo(False)
    write_line('Calling decorated foo with True')
    assert decorated_foo(True)

При выполнении этого скрипта должно быть получено следующее выходное значение:

Calling undecorated foo with False
about to log at DEBUG ...
about to log at INFO ...
about to log at WARNING ...
Calling undecorated foo with True
about to log at DEBUG ...
about to log at INFO ...
about to log at WARNING ...
about to log at ERROR ...
about to log at CRITICAL ...
Calling decorated foo with False
about to log at DEBUG ...
about to log at INFO ...
about to log at WARNING ...
Calling decorated foo with True
about to log at DEBUG ...
about to log at INFO ...
about to log at WARNING ...
about to log at ERROR ...
Actually logged at DEBUG
Actually logged at INFO
Actually logged at WARNING
Actually logged at ERROR
about to log at CRITICAL ...
Actually logged at CRITICAL

Как вы можете видеть, фактический вывод логирования происходит только тогда, когда записывается событие, уровень которого ERROR или выше, но в этом случае также регистрируются все предыдущие события с более низкими уровнями.

Конечно, вы можете использовать стандартные средства декорирования:

@log_if_errors(logger)
def foo(fail=False):
    ...

Отправка сообщений логирования по электронной почте с буферизацией

Чтобы продемонстрировать, как вы можете отправлять сообщения логирования по электронной почте таким образом, чтобы заданное количество сообщений отправлялось по одному электронному письму, можно создать подкласс BufferingHandler. В следующем примере, который вы можете адаптировать к своим потребностям, предоставлен простой тестовый инструмент, позволяющий запускать скрипт с аргументами командной строки, определяющими, как обычно отправлять вещи по SMTP. (Запустите загруженный скрипт со значением аргумента -h , чтобы увидеть необходимые и необязательные аргументы.)

import logging
import logging.handlers
import smtplib

class BufferingSMTPHandler(logging.handlers.BufferingHandler):
    def __init__(self, mailhost, port, username, password, fromaddr, toaddrs,
                 subject, capacity):
        logging.handlers.BufferingHandler.__init__(self, capacity)
        self.mailhost = mailhost
        self.mailport = port
        self.username = username
        self.password = password
        self.fromaddr = fromaddr
        if isinstance(toaddrs, str):
            toaddrs = [toaddrs]
        self.toaddrs = toaddrs
        self.subject = subject
        self.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s %(message)s"))

    def flush(self):
        if len(self.buffer) > 0:
            try:
                smtp = smtplib.SMTP(self.mailhost, self.mailport)
                smtp.starttls()
                smtp.login(self.username, self.password)
                msg = "From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n" % (self.fromaddr, ','.join(self.toaddrs), self.subject)
                for record in self.buffer:
                    s = self.format(record)
                    msg = msg + s + "\r\n"
                smtp.sendmail(self.fromaddr, self.toaddrs, msg)
                smtp.quit()
            except Exception:
                if logging.raiseExceptions:
                    raise
            self.buffer = []

if __name__ == '__main__':
    import argparse

    ap = argparse.ArgumentParser()
    aa = ap.add_argument
    aa('host', metavar='HOST', help='SMTP server')
    aa('--port', '-p', type=int, default=587, help='SMTP port')
    aa('user', metavar='USER', help='SMTP username')
    aa('password', metavar='PASSWORD', help='SMTP password')
    aa('to', metavar='TO', help='Addressee for emails')
    aa('sender', metavar='SENDER', help='Sender email address')
    aa('--subject', '-s',
       default='Test Logging email from Python logging module (buffering)',
       help='Subject of email')
    options = ap.parse_args()
    logger = logging.getLogger()
    logger.setLevel(logging.DEBUG)
    h = BufferingSMTPHandler(options.host, options.port, options.user,
                             options.password, options.sender,
                             options.to, options.subject, 10)
    logger.addHandler(h)
    for i in range(102):
        logger.info("Info index = %d", i)
    h.flush()
    h.close()

Если вы запустите этот скрипт и ваш SMTP-сервер правильно настроен, вы должны обнаружить, что он отправляет одиннадцать писем получателю, которого вы указали. Первые десять писем будут содержать по десять сообщений логирования, а одиннадцатое — два сообщения. Это в сумме 102 сообщения, как указано в сценарии.

Форматирование времени с использованием UTC (GMT) через конфигурацию

Иногда нужно форматировать время с использованием UTC, что можно сделать с помощью класса, например, UTCFormatter, показанного ниже:

import logging
import time

class UTCFormatter(logging.Formatter):
    converter = time.gmtime

и затем вы можете использовать UTCFormatter в своём коде вместо Formatter. Если вы хотите сделать это через конфигурацию, вы можете использовать API dictConfig() с подходом, проиллюстрированным в следующем полном примере:

import logging
import logging.config
import time

class UTCFormatter(logging.Formatter):
    converter = time.gmtime

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'utc': {
            '()': UTCFormatter,
            'format': '%(asctime)s %(message)s',
        },
        'local': {
            'format': '%(asctime)s %(message)s',
        }
    },
    'handlers': {
        'console1': {
            'class': 'logging.StreamHandler',
            'formatter': 'utc',
        },
        'console2': {
            'class': 'logging.StreamHandler',
            'formatter': 'local',
        },
    },
    'root': {
        'handlers': ['console1', 'console2'],
   }
}

if __name__ == '__main__':
    logging.config.dictConfig(LOGGING)
    logging.warning('The local time is %s', time.asctime())

При выполнении этого скрипта он должен вывести что-то вроде:

2015-10-17 12:53:29,501 The local time is Sat Oct 17 13:53:29 2015
2015-10-17 13:53:29,501 The local time is Sat Oct 17 13:53:29 2015

показывающего, как время форматируется как по локальному времени, так и по UTC, по одному для каждого обработчика.

Использование менеджера контекста для выборочного логирования

Бывают ситуации, когда полезно временно изменить конфигурацию логирования и вернуть ее обратно после выполнения каких-либо действий. Для этого менеджер контекста является наиболее очевидным способом сохранения и восстановления контекста логирования. Вот простой пример такого менеджера контекста, который позволяет по желанию изменить уровень логирования и добавить обработчик логирования исключительно в области действия менеджера контекста:

import logging
import sys

class LoggingContext:
    def __init__(self, logger, level=None, handler=None, close=True):
        self.logger = logger
        self.level = level
        self.handler = handler
        self.close = close

    def __enter__(self):
        if self.level is not None:
            self.old_level = self.logger.level
            self.logger.setLevel(self.level)
        if self.handler:
            self.logger.addHandler(self.handler)

    def __exit__(self, et, ev, tb):
        if self.level is not None:
            self.logger.setLevel(self.old_level)
        if self.handler:
            self.logger.removeHandler(self.handler)
        if self.handler and self.close:
            self.handler.close()
        # implicit return of None => don't swallow exceptions

Если вы указываете значение уровня, уровень логирования устанавливается в это значение в области действия блока with, покрытого менеджером контекста. Если вы указываете обработчик, он добавляется в логирование при входе в блок и удаляется при выходе из блока. Вы также можете попросить менеджера закрыть обработчик при выходе из блока — вы можете сделать это, если больше не нуждаетесь в обработчике.

Чтобы проиллюстрировать, как это работает, мы можем добавить следующий блок кода к вышеупомянутому:

if __name__ == '__main__':
    logger = logging.getLogger('foo')
    logger.addHandler(logging.StreamHandler())
    logger.setLevel(logging.INFO)
    logger.info('1. This should appear just once on stderr.')
    logger.debug('2. This should not appear.')
    with LoggingContext(logger, level=logging.DEBUG):
        logger.debug('3. This should appear once on stderr.')
    logger.debug('4. This should not appear.')
    h = logging.StreamHandler(sys.stdout)
    with LoggingContext(logger, level=logging.DEBUG, handler=h, close=True):
        logger.debug('5. This should appear twice - once on stderr and once on stdout.')
    logger.info('6. This should appear just once on stderr.')
    logger.debug('7. This should not appear.')

Изначально уровень логирования установлены в INFO, поэтому сообщение #1 отображается, а сообщение #2 нет. Затем уровень временно изменяется на DEBUG в следующем with блоке, и поэтому отображается сообщение #3. После выхода из блока уровень логирования восстанавливается до INFO, и поэтому сообщение #4 не отображается. В следующем with блоке уровень устанавливается на DEBUG снова, но также добавляется обработчик, записывающий в sys.stdout. Таким образом, сообщение #5 появляется дважды на консоли (один раз через stderr и один раз через stdout). После завершения оператора with, статус возвращается к исходному, поэтому сообщение #6 отображается (как сообщение #1), тогда как сообщение #7 не отображается (как сообщение #2).

Если мы запустим результирующий скрипт, результат будет следующим:

$ python logctx.py
1. This should appear just once on stderr.
3. This should appear once on stderr.
5. This should appear twice - once on stderr and once on stdout.
5. This should appear twice - once on stderr and once on stdout.
6. This should appear just once on stderr.

Если мы запустим его снова, но перенаправим stderr в /dev/null, мы увидим следующее, что является единственным сообщением, записанным в stdout:

$ python logctx.py 2>/dev/null
5. This should appear twice - once on stderr and once on stdout.

Еще раз, но перенаправляя stdout в /dev/null, мы получим:

$ python logctx.py >/dev/null
1. This should appear just once on stderr.
3. This should appear once on stderr.
5. This should appear twice - once on stderr and once on stdout.
6. This should appear just once on stderr.

В этом случае сообщение #5, напечатанное в stdout не отображается, как ожидалось.

Конечно, описанный подход можно обобщить, например, для временного добавления фильтров логирования. Обратите внимание, что приведенный выше код работает как в Python 2, так и в Python 3.

Шаблон приложения командной строки

Вот пример, который показывает, как вы можете:

  • Использовать уровень логирования, основанный на аргументах командной строки
  • Перенаправлять в несколько подкоманд в отдельных файлах, все логируя на одном уровне последовательным образом
  • Использовать простую минимальную конфигурацию

Предположим, что у нас есть приложение командной строки, задача которого — остановить, запустить или перезапустить некоторые сервисы. Для иллюстрации это может быть организовано как файл app.py, который является основным скриптом приложения, с отдельными командами, реализованными в start.py, stop.py и restart.py. Предположим также, что мы хотим контролировать подробность приложения с помощью аргумента командной строки, по умолчанию установленного в logging.INFO. Вот как можно написать app.py:

import argparse
import importlib
import logging
import os
import sys

def main(args=None):
    scriptname = os.path.basename(__file__)
    parser = argparse.ArgumentParser(scriptname)
    levels = ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
    parser.add_argument('--log-level', default='INFO', choices=levels)
    subparsers = parser.add_subparsers(dest='command',
                                       help='Available commands:')
    start_cmd = subparsers.add_parser('start', help='Start a service')
    start_cmd.add_argument('name', metavar='NAME',
                           help='Name of service to start')
    stop_cmd = subparsers.add_parser('stop',
                                     help='Stop one or more services')
    stop_cmd.add_argument('names', metavar='NAME', nargs='+',
                          help='Name of service to stop')
    restart_cmd = subparsers.add_parser('restart',
                                        help='Restart one or more services')
    restart_cmd.add_argument('names', metavar='NAME', nargs='+',
                             help='Name of service to restart')
    options = parser.parse_args()
    # the code to dispatch commands could all be in this file. For the purposes
    # of illustration only, we implement each command in a separate module.
    try:
        mod = importlib.import_module(options.command)
        cmd = getattr(mod, 'command')
    except (ImportError, AttributeError):
        print('Unable to find the code for command \'%s\'' % options.command)
        return 1
    # Could get fancy here and load configuration from file or dictionary
    logging.basicConfig(level=options.log_level,
                        format='%(levelname)s %(name)s %(message)s')
    cmd(options)

if __name__ == '__main__':
    sys.exit(main())

А команды start, stop и restart могут быть реализованы в отдельных модулях, например, для запуска:

# start.py
import logging

logger = logging.getLogger(__name__)

def command(options):
    logger.debug('About to start %s', options.name)
    # actually do the command processing here ...
    logger.info('Started the \'%s\' service.', options.name)

и аналогично для остановки:

# stop.py
import logging

logger = logging.getLogger(__name__)

def command(options):
    n = len(options.names)
    if n == 1:
        plural = ''
        services = '\'%s\'' % options.names[0]
    else:
        plural = 's'
        services = ', '.join('\'%s\'' % name for name in options.names)
        i = services.rfind(', ')
        services = services[:i] + ' and ' + services[i + 2:]
    logger.debug('About to stop %s', services)
    # actually do the command processing here ...
    logger.info('Stopped the %s service%s.', services, plural)

и аналогично для перезапуска:

# restart.py
import logging

logger = logging.getLogger(__name__)

def command(options):
    n = len(options.names)
    if n == 1:
        plural = ''
        services = '\'%s\'' % options.names[0]
    else:
        plural = 's'
        services = ', '.join('\'%s\'' % name for name in options.names)
        i = services.rfind(', ')
        services = services[:i] + ' and ' + services[i + 2:]
    logger.debug('About to restart %s', services)
    # actually do the command processing here ...
    logger.info('Restarted the %s service%s.', services, plural)

Если мы запустим это приложение с уровнем журнала по умолчанию, мы получим вывод, подобный этому:

$ python app.py start foo
INFO start Started the 'foo' service.

$ python app.py stop foo bar
INFO stop Stopped the 'foo' and 'bar' services.

$ python app.py restart foo bar baz
INFO restart Restarted the 'foo', 'bar' and 'baz' services.

Первое слово — это уровень логирования, а второе — имя модуля или пакета, где событие было зарегистрировано.

Если мы изменим уровень логирования, то можем изменить отображаемую в журнале информацию. Например, если мы хотим больше информации:

$ python app.py --log-level DEBUG start foo
DEBUG start About to start foo
INFO start Started the 'foo' service.

$ python app.py --log-level DEBUG stop foo bar
DEBUG stop About to stop 'foo' and 'bar'
INFO stop Stopped the 'foo' and 'bar' services.

$ python app.py --log-level DEBUG restart foo bar baz
DEBUG restart About to restart 'foo', 'bar' and 'baz'
INFO restart Restarted the 'foo', 'bar' and 'baz' services.

А если мы хотим меньше:

$ python app.py --log-level WARNING start foo
$ python app.py --log-level WARNING stop foo bar
$ python app.py --log-level WARNING restart foo bar baz

В этом случае команды ничего не выводят на консоль, так как ничего на уровне WARNING или выше ими не регистрируется.

Qt-интерфейс для логирования

Время от времени возникает вопрос о том, как вести журнал в приложении с графическим интерфейсом. Фреймворк Qt — популярный кроссплатформенный фреймворк для графических интерфейсов с Python-связями, использующими библиотеки PySide2 или PyQt5.

Следующий пример показывает, как вести журнал в Qt GUI. В нем вводится простой QtHandler класс, который принимает вызываемую функцию, которая должна быть слотом в главном потоке, выполняющим обновления GUI. Также создается рабочий поток, чтобы показать, как вы можете вести журнал в GUI как из самого интерфейса (через кнопку для ручного логирования), так и из рабочего потока, выполняющего работу в фоновом режиме (здесь просто записываются сообщения случайных уровней с случайными короткими задержками между ними).

Рабочий поток реализован с использованием класса Qt QThread вместо модуля threading, так как есть обстоятельства, в которых необходимо использовать QThread, что обеспечивает лучшую интеграцию с другими компонентами Qt.

Код должен работать с недавними выпусками как PySide2, так и PyQt5. Вы должны быть в состоянии адаптировать подход к более ранним версиям Qt. Подробную информацию см. в комментариях к фрагменту кода.

import datetime
import logging
import random
import sys
import time

# Deal with minor differences between PySide2 and PyQt5
try:
    from PySide2 import QtCore, QtGui, QtWidgets
    Signal = QtCore.Signal
    Slot = QtCore.Slot
except ImportError:
    from PyQt5 import QtCore, QtGui, QtWidgets
    Signal = QtCore.pyqtSignal
    Slot = QtCore.pyqtSlot


logger = logging.getLogger(__name__)


#
# Signals need to be contained in a QObject or subclass in order to be correctly
# initialized.
#
class Signaller(QtCore.QObject):
    signal = Signal(str, logging.LogRecord)

#
# Output to a Qt GUI is only supposed to happen on the main thread. So, this
# handler is designed to take a slot function which is set up to run in the main
# thread. In this example, the function takes a string argument which is a
# formatted log message, and the log record which generated it. The formatted
# string is just a convenience - you could format a string for output any way
# you like in the slot function itself.
#
# You specify the slot function to do whatever GUI updates you want. The handler
# doesn't know or care about specific UI elements.
#
class QtHandler(logging.Handler):
    def __init__(self, slotfunc, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.signaller = Signaller()
        self.signaller.signal.connect(slotfunc)

    def emit(self, record):
        s = self.format(record)
        self.signaller.signal.emit(s, record)

#
# This example uses QThreads, which means that the threads at the Python level
# are named something like "Dummy-1". The function below gets the Qt name of the
# current thread.
#
def ctname():
    return QtCore.QThread.currentThread().objectName()


#
# Used to generate random levels for logging.
#
LEVELS = (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR,
          logging.CRITICAL)

#
# This worker class represents work that is done in a thread separate to the
# main thread. The way the thread is kicked off to do work is via a button press
# that connects to a slot in the worker.
#
# Because the default threadName value in the LogRecord isn't much use, we add
# a qThreadName which contains the QThread name as computed above, and pass that
# value in an "extra" dictionary which is used to update the LogRecord with the
# QThread name.
#
# This example worker just outputs messages sequentially, interspersed with
# random delays of the order of a few seconds.
#
class Worker(QtCore.QObject):
    @Slot()
    def start(self):
        extra = {'qThreadName': ctname() }
        logger.debug('Started work', extra=extra)
        i = 1
        # Let the thread run until interrupted. This allows reasonably clean
        # thread termination.
        while not QtCore.QThread.currentThread().isInterruptionRequested():
            delay = 0.5 + random.random() * 2
            time.sleep(delay)
            level = random.choice(LEVELS)
            logger.log(level, 'Message after delay of %3.1f: %d', delay, i, extra=extra)
            i += 1

#
# Implement a simple UI for this cookbook example. This contains:
#
# * A read-only text edit window which holds formatted log messages
# * A button to start work and log stuff in a separate thread
# * A button to log something from the main thread
# * A button to clear the log window
#
class Window(QtWidgets.QWidget):

    COLORS = {
        logging.DEBUG: 'black',
        logging.INFO: 'blue',
        logging.WARNING: 'orange',
        logging.ERROR: 'red',
        logging.CRITICAL: 'purple',
    }

    def __init__(self, app):
        super().__init__()
        self.app = app
        self.textedit = te = QtWidgets.QPlainTextEdit(self)
        # Set whatever the default monospace font is for the platform
        f = QtGui.QFont('nosuchfont')
        f.setStyleHint(f.Monospace)
        te.setFont(f)
        te.setReadOnly(True)
        PB = QtWidgets.QPushButton
        self.work_button = PB('Start background work', self)
        self.log_button = PB('Log a message at a random level', self)
        self.clear_button = PB('Clear log window', self)
        self.handler = h = QtHandler(self.update_status)
        # Remember to use qThreadName rather than threadName in the format string.
        fs = '%(asctime)s %(qThreadName)-12s %(levelname)-8s %(message)s'
        formatter = logging.Formatter(fs)
        h.setFormatter(formatter)
        logger.addHandler(h)
        # Set up to terminate the QThread when we exit
        app.aboutToQuit.connect(self.force_quit)

        # Lay out all the widgets
        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(te)
        layout.addWidget(self.work_button)
        layout.addWidget(self.log_button)
        layout.addWidget(self.clear_button)
        self.setFixedSize(900, 400)

        # Connect the non-worker slots and signals
        self.log_button.clicked.connect(self.manual_update)
        self.clear_button.clicked.connect(self.clear_display)

        # Start a new worker thread and connect the slots for the worker
        self.start_thread()
        self.work_button.clicked.connect(self.worker.start)
        # Once started, the button should be disabled
        self.work_button.clicked.connect(lambda : self.work_button.setEnabled(False))

    def start_thread(self):
        self.worker = Worker()
        self.worker_thread = QtCore.QThread()
        self.worker.setObjectName('Worker')
        self.worker_thread.setObjectName('WorkerThread')  # for qThreadName
        self.worker.moveToThread(self.worker_thread)
        # This will start an event loop in the worker thread
        self.worker_thread.start()

    def kill_thread(self):
        # Just tell the worker to stop, then tell it to quit and wait for that
        # to happen
        self.worker_thread.requestInterruption()
        if self.worker_thread.isRunning():
            self.worker_thread.quit()
            self.worker_thread.wait()
        else:
            print('worker has already exited.')

    def force_quit(self):
        # For use when the window is closed
        if self.worker_thread.isRunning():
            self.kill_thread()

    # The functions below update the UI and run in the main thread because
    # that's where the slots are set up

    @Slot(str, logging.LogRecord)
    def update_status(self, status, record):
        color = self.COLORS.get(record.levelno, 'black')
        s = '<pre><font color="%s">%s</font></pre>' % (color, status)
        self.textedit.appendHtml(s)

    @Slot()
    def manual_update(self):
        # This function uses the formatted message passed in, but also uses
        # information from the record to format the message in an appropriate
        # color according to its severity (level).
        level = random.choice(LEVELS)
        extra = {'qThreadName': ctname() }
        logger.log(level, 'Manually logged!', extra=extra)

    @Slot()
    def clear_display(self):
        self.textedit.clear()


def main():
    QtCore.QThread.currentThread().setObjectName('MainThread')
    logging.getLogger().setLevel(logging.DEBUG)
    app = QtWidgets.QApplication(sys.argv)
    example = Window(app)
    example.show()
    sys.exit(app.exec_())

if __name__=='__main__':
    main()

Логирование в syslog с поддержкой RFC5424

Хотя RFC 5424 датируется 2009 годом, большинство серверов syslog по умолчанию настроены на использование более старого RFC 3164 от 2001 года. Когда logging был добавлен в Python в 2003 году, он поддерживал более ранний (и единственный существовавший тогда) протокол. Поскольку RFC5424 вышел, в серверах syslog не было широко распространённого его внедрения, поэтому функциональность SysLogHandler не была обновлена.

RFC 5424 содержит некоторые полезные функции, такие как поддержка структурированных данных, и если вам нужно вести журнал в сервер syslog с поддержкой этой функции, вы можете сделать это с помощью обработчика с наследованием, который выглядит примерно так:

import datetime
import logging.handlers
import re
import socket
import time

class SysLogHandler5424(logging.handlers.SysLogHandler):

    tz_offset = re.compile(r'([+-]\d{2})(\d{2})$')
    escaped = re.compile(r'([\]"\\])')

    def __init__(self, *args, **kwargs):
        self.msgid = kwargs.pop('msgid', None)
        self.appname = kwargs.pop('appname', None)
        super().__init__(*args, **kwargs)

    def format(self, record):
        version = 1
        asctime = datetime.datetime.fromtimestamp(record.created).isoformat()
        m = self.tz_offset.match(time.strftime('%z'))
        has_offset = False
        if m and time.timezone:
            hrs, mins = m.groups()
            if int(hrs) or int(mins):
                has_offset = True
        if not has_offset:
            asctime += 'Z'
        else:
            asctime += f'{hrs}:{mins}'
        try:
            hostname = socket.gethostname()
        except Exception:
            hostname = '-'
        appname = self.appname or '-'
        procid = record.process
        msgid = '-'
        msg = super().format(record)
        sdata = '-'
        if hasattr(record, 'structured_data'):
            sd = record.structured_data
            # This should be a dict where the keys are SD-ID and the value is a
            # dict mapping PARAM-NAME to PARAM-VALUE (refer to the RFC for what these
            # mean)
            # There's no error checking here - it's purely for illustration, and you
            # can adapt this code for use in production environments
            parts = []

            def replacer(m):
                g = m.groups()
                return '\\' + g[0]

            for sdid, dv in sd.items():
                part = f'[{sdid}'
                for k, v in dv.items():
                    s = str(v)
                    s = self.escaped.sub(replacer, s)
                    part += f' {k}="{s}"'
                part += ']'
                parts.append(part)
            sdata = ''.join(parts)
        return f'{version} {asctime} {hostname} {appname} {procid} {msgid} {sdata} {msg}'

Вам необходимо ознакомиться с RFC 5424, чтобы полностью понять приведенный выше код, и возможно, у вас будут немного разные потребности (например, в том, как передавать структурированные данные в журнал). Тем не менее, вышеизложенное должно быть применимо к вашим специфическим потребностям. С использованием вышеупомянутого обработчика вы бы передавали структурированные данные примерно так:

sd = {
    'foo@12345': {'bar': 'baz', 'baz': 'bozz', 'fizz': r'buzz'},
    'foo@54321': {'rab': 'baz', 'zab': 'bozz', 'zzif': r'buzz'}
}
extra = {'structured_data': sd}
i = 1
logger.debug('Message %d', i, extra=extra)

Как обращаться с логгером как с потоком вывода

Иногда вам нужно взаимодействовать с API стороннего приложения, которое ожидает объект, подобный файлу, для записи, но вы хотите направить вывод API в логгер. Это можно сделать с помощью класса, который оборачивает логгер с API, похожим на файл. Вот небольшой скрипт, демонстрирующий такой класс:

import logging

class LoggerWriter:
    def __init__(self, logger, level):
        self.logger = logger
        self.level = level

    def write(self, message):
        if message != '\n':  # avoid printing bare newlines, if you like
            self.logger.log(self.level, message)

    def flush(self):
        # doesn't actually do anything, but might be expected of a file-like
        # object - so optional depending on your situation
        pass

    def close(self):
        # doesn't actually do anything, but might be expected of a file-like
        # object - so optional depending on your situation. You might want
        # to set a flag so that later calls to write raise an exception
        pass

def main():
    logging.basicConfig(level=logging.DEBUG)
    logger = logging.getLogger('demo')
    info_fp = LoggerWriter(logger, logging.INFO)
    debug_fp = LoggerWriter(logger, logging.DEBUG)
    print('An INFO message', file=info_fp)
    print('A DEBUG message', file=debug_fp)

if __name__ == "__main__":
    main()

При запуске этого скрипта он выводит

INFO:demo:An INFO message
DEBUG:demo:A DEBUG message

Вы также можете использовать LoggerWriter для перенаправления sys.stdout и sys.stderr следующим образом:

import sys

sys.stdout = LoggerWriter(logger, logging.INFO)
sys.stderr = LoggerWriter(logger, logging.WARNING)

Это следует сделать после настройки логирования для ваших нужд. В приведенном выше примере вызов basicConfig() делает это (используя значение sys.stderr до того, как оно перезаписывается экземпляром LoggerWriter). Тогда вы получите результат такого типа:

>>> print('Foo')
INFO:demo:Foo
>>> print('Bar', file=sys.stderr)
WARNING:demo:Bar
>>>

Конечно, приведенные выше примеры показывают вывод в соответствии с форматом, используемым в basicConfig(), но вы можете использовать другой форматтер при настройке логирования.

Обратите внимание, что с помощью вышеописанной схемы вы несколько зависите от буферизации и последовательности вызовов записи, которые вы перехватываете. Например, при определении LoggerWriter выше, если у вас есть фрагмент

sys.stderr = LoggerWriter(logger, logging.WARNING)
1 / 0

то при запуске скрипта получаем

WARNING:demo:Traceback (most recent call last):

WARNING:demo:  File "/home/runner/cookbook-loggerwriter/test.py", line 53, in <module>

WARNING:demo:
WARNING:demo:main()
WARNING:demo:  File "/home/runner/cookbook-loggerwriter/test.py", line 49, in main

WARNING:demo:
WARNING:demo:1 / 0
WARNING:demo:ZeroDivisionError
WARNING:demo::
WARNING:demo:division by zero

Как видите, этот вывод не идеален. Это происходит потому, что код, который пишет в sys.stderr, делает несколько записей, каждая из которых приводит к отдельной строке журнала (например, три последних строки выше). Чтобы обойти эту проблему, вам нужно буферизовать вещи и выводить строки журнала только при появлении новых строк. Давайте воспользуемся немного лучшей реализацией LoggerWriter:

class BufferingLoggerWriter(LoggerWriter):
    def __init__(self, logger, level):
        super().__init__(logger, level)
        self.buffer = ''

    def write(self, message):
        if '\n' not in message:
            self.buffer += message
        else:
            parts = message.split('\n')
            if self.buffer:
                s = self.buffer + parts.pop(0)
                self.logger.log(self.level, s)
            self.buffer = parts.pop()
            for part in parts:
                self.logger.log(self.level, part)

Это просто буферизует вещи, пока не будет встречена новая строка, а затем записывает полные строки. С этим подходом вы получаете лучший вывод:

WARNING:demo:Traceback (most recent call last):
WARNING:demo:  File "/home/runner/cookbook-loggerwriter/main.py", line 55, in <module>
WARNING:demo:    main()
WARNING:demo:  File "/home/runner/cookbook-loggerwriter/main.py", line 52, in main
WARNING:demo:    1/0
WARNING:demo:ZeroDivisionError: division by zero

Способы, которых следует избегать

Хотя предыдущие разделы описывают способы выполнения задач, с которыми вы можете столкнуться, стоит упомянуть некоторые шаблоны использования, которые являются неэффективными и поэтому в большинстве случаев следует избегать. Следующие разделы расположены без определённого порядка.

Открытие одного и того же файла журнала несколько раз

В Windows, как правило, нельзя открыть один и тот же файл несколько раз, так как это приведёт к ошибке «Файл используется другой процесс». Однако в POSIX-системах открытие одного и того же файла несколько раз не приведёт к ошибкам. Это может произойти случайно, например, из-за:

  • Добавления обработчика файла более одного раза, который ссылается на один и тот же файл (например, из-за ошибки копирования/вставки/забывчивости изменения).
  • Открытия двух файлов, которые выглядят по-разному, так как имеют разные имена, но на самом деле являются одинаковыми, потому что один из них — символическая ссылка на другой.
  • Разделения процесса, после чего как родительский, так и дочерний процессы ссылаются на один и тот же файл. Это может произойти при использовании модуля multiprocessing, например.

Открытие файла несколько раз может работать в большинстве случаев, но на практике это может привести к ряду проблем:

  • Выходные данные журнала могут быть искажены, потому что несколько потоков или процессов пытаются записать в один и тот же файл. Хотя регистрация защищает от одновременного использования одного и того же экземпляра обработчика несколькими потоками, такой защиты нет, если одновременные записи пытаются произвести два разных потока, используя два разных экземпляра обработчика, которые указывают на один и тот же файл.
  • Попытка удалить файл (например, во время ротации файлов) завершается без ошибок, потому что на него есть другая ссылка. Это может привести к путанице и потерянному времени на отладку — записи журнала оказываются в неожиданных местах или теряются совсем. Или файл, который должен был быть перемещён, остаётся на месте и неожиданно увеличивается в размере, несмотря на то, что ротация на основе размера, предположительно, включена.

Используйте методы, описанные в Журналирование в один файл из нескольких процессов, чтобы избежать таких проблем.

Использование логгеров как атрибутов в классе или передача их в качестве параметров

Хотя могут быть необычные случаи, когда вам нужно это сделать, в целом это бесполезно, потому что логгеры являются синглтонами. Код всегда может получить доступ к экземпляру логгера по имени, используя logging.getLogger(name), поэтому передача экземпляров и хранение их как атрибутов экземпляра бессмысленно. Обратите внимание, что в других языках, таких как Java и C#, логгеры часто являются статическими атрибутами класса. Однако этот шаблон не имеет смысла в Python, где модуль (а не класс) является единицей декомпозиции программного обеспечения.

Добавление обработчиков, отличных от NullHandler, в логгер в библиотеке

Настройка ведения журнала путём добавления обработчиков, форматеров и фильтров является обязанностью разработчика приложения, а не разработчика библиотеки. Если вы поддерживаете библиотеку, убедитесь, что вы не добавляете обработчики в ваши логгеры, кроме экземпляра NullHandler.

Создание большого количества логгеров

Логгеры являются синглтонами, которые никогда не освобождаются во время выполнения скрипта, поэтому создание большого количества логгеров займёт память, которую затем нельзя будет освободить. Вместо создания логгера для каждого обработанного файла или установленного сетевого соединения используйте существующие механизмы для передачи контекстной информации в ваши логи и ограничьте создаваемые логгеры теми, которые описывают области вашего приложения (обычно модули, но иногда несколько более детально).

Другие ресурсы

См. также

Module logging

Ссылка на API для модуля ведения журнала.

Module logging.config

API конфигурации для модуля ведения журнала.

Module logging.handlers

Полезные обработчики, включённые в модуль ведения журнала.

Базовый учебник

Расширенный учебник

© 2001–2023 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.11/howto/logging-cookbook.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API