"""Loggers
Loggers module.
"""
import logging
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from logging import Logger as BaseLogger, _acquireLock, _releaseLock
from logging.handlers import RotatingFileHandler
from os import path
from typing import Optional, Union, List, Any, final
from .rid import DEFAULT_REQUEST_ID, requestIdCtx
DEBUG_DEFAULT_FORMAT_STRING = (
"[{process:07d} {asctime}] {levelname} [{filename} {lineno}]: {appName} {requestId}{channel}: {message}"
)
# (str): format string for non debug logs
NOT_DEBUG_DEFAULT_FORMAT_STRING = "[{process:07d} {asctime}] {levelname}: {appName} {requestId}{channel}: {message}"
# (int): size of one megabyte in bytes
SIZE_OF_MB = 2 ** 20
def _adaptChannelForRecordFormat(channel: str) -> str:
"""Adopt channel name for record"""
return f": {channel}" if channel else ""
[docs]def generateStdoutLogHandler() -> logging.StreamHandler:
"""
Generate stout log handler
Returns:
new stdout handler
"""
handler = logging.StreamHandler(sys.stdout)
return handler
[docs]def setUpHandler(handler: logging.Handler, multilineStackTrace: bool, level: int):
"""
Setup log handler. set log record formatter, level
Args:
handler: handler
multilineStackTrace: allow a multiline line stack trace message
level: log level
"""
handler.level = level
if level == logging.DEBUG:
fmtString = DEBUG_DEFAULT_FORMAT_STRING
else:
fmtString = NOT_DEBUG_DEFAULT_FORMAT_STRING
if multilineStackTrace:
handler.setFormatter(Formatter(fmt=fmtString, style="{"))
else:
handler.setFormatter(OneLineExceptionFormatter(fmt=fmtString, style="{"))
[docs]def generateFileHandler(logFileName: str, maxSize: int = 1024) -> logging.FileHandler:
"""
Generate file handler
Args:
logFileName: filename
maxSize: max size of log file
Returns:
rotating file handler if maxSize> 0 otherwise usual file handler
"""
if maxSize > 0:
handler = RotatingFileHandler(logFileName, maxBytes=maxSize * SIZE_OF_MB, backupCount=5,)
else:
handler = logging.FileHandler(logFileName)
return handler
[docs]@contextmanager
def logHandlerLock():
"""
Contextmanager for access management to logger handlers.
All changes handlers operations must be thread safe.
"""
_acquireLock()
try:
yield
finally:
_releaseLock()
[docs]@dataclass
class LogSettings:
"""
Logger settings container
"""
# app name
appName: str
# log level
logLevel: int
# log records time
logTime: str
# folder for logs
folderForLog: str
# max size of log file
maxSize: int
# log file suffix
logNameSuffix: Union[str, None]
# write or not logs to stdout
logToStdOut: bool
# write or not logs to file
logToFile: bool
# allow a multiline line stack trace message
multilineStackTrace: bool
[docs]class LogDest(Enum):
"""
Log destination
"""
file = "file" #: destination is log file
stdout = "stdout" #: destination is stdout
[docs]class Logger(BaseLogger):
"""
Application logger.
Global logger class. This class realize following features:
1. Class contains global handlers. All loggers will be to have this handlers.
2. Class replaces itself default logger class from standard library (logging.Logger). All third party loggers
(based on logging.Logger) will be use handlers of this class
3. method reinitialize updates all created loggers.
All features based on class variable `_handlers` and property `handlers`. They patch attribute `handlers` of
logger from standard library and make them globals.
"""
# logger settings
_settings: Optional[LogSettings] = None
# logs fie handlers
_logsFileHandlers: List[logging.FileHandler] = []
# stdout handler
_stdoutHandler: Optional[logging.StreamHandler] = None
# logger default request id
defaultRequestId: str = DEFAULT_REQUEST_ID
# global logger handlers
_handlers = []
[docs] @staticmethod
def getLogLevel(logLevel) -> int:
"""
Get log level from config for logger.
Returns:
int: if logLevel not set or incorrect will return logging.NOTSET
"""
if logLevel == "DEBUG":
return logging.DEBUG
if logLevel == "ERROR":
return logging.ERROR
if logLevel == "INFO":
return logging.INFO
if logLevel == "WARNING":
return logging.WARNING
return logging.NOTSET
[docs] @classmethod
def initiate(
cls,
appName: str = "",
logLevel: str = "DEBUG",
logTime: str = "LOCAL",
folderForLog: str = "./",
maxSize: int = 1024,
logNameSuffix: Optional[str] = None,
logToStdOut: bool = True,
logToFile: bool = True,
multilineStackTrace: bool = True,
) -> None:
"""
Initiate class settings.
Args:
appName: application name
logLevel: log level
logTime: time of logs
folderForLog: folder with log-files
maxSize: max log file size, 1024 mb for default, 0 - disable rotate
logNameSuffix: optional suffix name for log name files.
logToStdOut: flush log to stdout
logToFile: flush log to a log file
multilineStackTrace: allow a multiline line stack trace message
"""
cls.reInitialize(
appName=appName,
logToFile=logToFile,
logToStdOut=logToStdOut,
multilineStackTrace=multilineStackTrace,
maxSize=maxSize,
logNameSuffix=logNameSuffix,
folderForLog=folderForLog,
logLevel=logLevel,
logTime=logTime,
)
cls.updateDefaultPythonLoggers()
@classmethod
def _update3partyLogger(cls, thirdPartyLogger: logging.Logger):
"""
Update 3-party logger. set up handlers, log level and other
Args:
thirdPartyLogger: 3-party logger
"""
if isinstance(thirdPartyLogger, cls):
return
thirdPartyLogger.level = logging.WARNING
# close old handlers
for handler in thirdPartyLogger.handlers:
handler.close()
# setup global hendlers
with logHandlerLock():
thirdPartyLogger.handlers = cls._handlers
# set up a record updating with extra data
def _filter(record):
record.appName = cls._settings.appName
record.requestId = cls.defaultRequestId
record.channel = _adaptChannelForRecordFormat(thirdPartyLogger.name)
return thirdPartyLogger.__class__.filter(thirdPartyLogger, record)
thirdPartyLogger.filter = _filter
[docs] @classmethod
def updateDefaultPythonLoggers(cls):
"""
setup this class as default class for 3-party loggers, which use logging.getLogger, manager.getLogger,
getLoggerClass.
"""
# upgrade already created loggers
with logHandlerLock():
for thirdPartylogger in logging.Logger.manager.loggerDict.values():
# set up created 3-party loggers
if not isinstance(thirdPartylogger, BaseLogger):
continue
if isinstance(thirdPartylogger, Logger):
# skip our loggers
continue
cls._update3partyLogger(thirdPartylogger)
class ThirdPartyLoggers(Logger):
"""Third party logger factory"""
level = logging.WARNING
# set up class for future loggers
cls.manager.setLoggerClass(ThirdPartyLoggers)
logging.setLoggerClass(ThirdPartyLoggers)
[docs] @classmethod
def reInitializeStdoutLogging(cls, settings: LogSettings):
"""
Re-Initialize stdout log handlers. Closes old handlers and create new if it iis needed
Args:
settings: settings for re-initialization
"""
if cls._stdoutHandler:
cls.removeHandler(cls._stdoutHandler)
if not cls._stdoutHandler.stream.closed:
# Let capsys monkeypatch the stream
cls._stdoutHandler.close()
cls._stdoutHandler = None
if settings.logToStdOut:
cls._stdoutHandler = generateStdoutLogHandler()
setUpHandler(cls._stdoutHandler, multilineStackTrace=settings.multilineStackTrace, level=settings.logLevel)
cls.addHandler(cls._stdoutHandler)
[docs] @classmethod
@final
def addHandler(cls, hdlr: logging.Handler) -> None:
"""
Add handlers to all loggers
Overwrite original method. Before it was instance method, now - global classmethod
Args:
hdlr: log handler
"""
with logHandlerLock():
if hdlr not in cls._handlers:
cls._handlers.append(hdlr)
[docs] @classmethod
@final
def removeHandler(cls, hdlr: logging.Handler):
"""
Remove the specified handler from all loggers.
Overwrite original method. Before it was instance method, now - global classmethod
Args:
hdlr: log handler
"""
with logHandlerLock():
if hdlr in cls._handlers:
cls._handlers.remove(hdlr)
[docs] @classmethod
def reInitializeFileLogging(cls, settings: LogSettings):
"""
Re-Initialize file logs handlers. Closes old handlers and create new if it iis needed
Args:
settings: settings for re-initialization
"""
for handler in cls._logsFileHandlers:
handler.close()
cls.removeHandler(handler)
cls._logsFileHandlers = []
if settings.logToFile:
folderForLog = settings.folderForLog
appName = settings.appName
logNameSuffix = settings.logNameSuffix
multilineStackTrace = settings.multilineStackTrace
for fileLogLevel in ("INFO", "ERROR", "DEBUG", "WARNING"):
if settings.logLevel > getattr(logging, fileLogLevel):
continue
if not logNameSuffix:
logFileName = path.join(folderForLog, f"{appName}_{fileLogLevel}.txt")
else:
logFileName = path.join(folderForLog, f"{appName}_{fileLogLevel}_{logNameSuffix}.txt")
mStackTrace = multilineStackTrace if fileLogLevel != "DEBUG" else True
handler = generateFileHandler(logFileName, settings.maxSize)
setUpHandler(handler, mStackTrace, level=getattr(logging, fileLogLevel))
cls.addHandler(handler)
cls._logsFileHandlers.append(handler)
[docs] @classmethod
def reInitializeLogTime(cls, settings: LogSettings):
"""
Re Initialize log time. Update time converter of global formatter
Args:
settings: settings for re-initialization
"""
Formatter.converter = time.localtime if settings.logTime == "LOCAL" else time.gmtime
[docs] @classmethod
def reInitialize(
cls,
appName: str = "",
logLevel: str = "DEBUG",
logTime: str = "LOCAL",
folderForLog: str = "./",
maxSize: int = 1024,
logNameSuffix: Optional[str] = None,
logToStdOut: bool = True,
logToFile: bool = True,
multilineStackTrace: bool = True,
force: bool = False,
) -> None:
"""
Re-Initiate class settings.
Args:
appName: application name
logLevel: log level
logTime: time of logs
folderForLog: folder with log-files
maxSize: max log file size, 1024 mb for default, 0 - disable rotate
logNameSuffix: optional suffix name for log name files.
logToStdOut: flush log to stdout
logToFile: flush log to a log file
multilineStackTrace: allow a multiline line stack trace message
force: reinitialize all settings
"""
_settings = LogSettings(
maxSize=maxSize,
appName=appName,
logTime=logTime,
logLevel=cls.getLogLevel(logLevel),
logNameSuffix=logNameSuffix,
logToFile=logToFile,
logToStdOut=logToStdOut,
folderForLog=folderForLog,
multilineStackTrace=multilineStackTrace,
)
if _settings == cls._settings and not force:
return
try:
cls.reInitializeStdoutLogging(_settings)
cls.reInitializeFileLogging(_settings)
cls.reInitializeLogTime(_settings)
except Exception:
# fallback to old settings, try set correct Logger state for logging errors
if cls._settings is not None:
cls.reInitializeStdoutLogging(cls._settings)
cls.reInitializeFileLogging(cls._settings)
cls.reInitializeLogTime(cls._settings)
raise
cls._settings = _settings
@property
def logTime(self) -> str:
"""
Get current log time format
Returns:
logTime from settings
"""
return self._settings.logTime
@property
def level(self) -> int:
"""
Overwrite property. Not save log level to an in instance of the Logger for correct reinitialize loggers.
Returns:
class log level
"""
return self._settings.logLevel
@level.setter
def level(self, level: int):
"""
Set log level stub. need for supper().__init__
Args:
level: new log level
Warnings:
Use only `initiate` and `reInitialize` functions
"""
@property
def requestId(self) -> str:
"""
Get current request id
Returns:
request id. priority: 1) request id from __init__ 2) from requestIdCtx 3) default
"""
return self._requestId or requestIdCtx.get() or self.defaultRequestId
@property
@final
def handlers(self) -> List[logging.Handler]:
"""
Get list of global handlers. Rewrite local handlers from logging.Logger
Not save handlers to an in instance of the Logger for correct reinitialize loggers. All created loggers
have these handlers.
Returns:
list of global handlers.
"""
return self._handlers
@handlers.setter
@final
def handlers(self, handlers: Any):
"""
Set handlers stub. need for supper().__init__
Args:
handlers: new handlers
Warnings:
Use only `initiate` and `reInitialize` functions
"""
[docs] def filter(self, record: logging.LogRecord) -> bool:
"""
Add extra data (request id and app name) to record
Args:
record: record
Returns:
result of filters
"""
record.appName = self._settings.appName
record.requestId = self.requestId
record.channel = _adaptChannelForRecordFormat(self.channel)
return super().filter(record)
[docs] def exception(self, *args, **kwargs,) -> None: #: pylint: disable-msg=W0221
"""
Exception without msg support.
Args:
*args: args
**kwargs: kwargs
"""
if len(args) == 0 and "msg" not in kwargs:
return super().exception("Uncaught exception occurred", *args, **kwargs)
return super().exception(*args, **kwargs)
def __init__(self, template: str = "", requestId: Optional[str] = None,) -> None:
# pylint: disable-msg=W0613
"""
Init logger.
Args:
requestId: user defined request id
template: string for marking logs. Typical usage - request id.
"""
self._requestId = requestId
self.channel = template
super().__init__(template)
# global service logger
logger = Logger() #: pylint: disable-msg=C0103