# SPDX-FileCopyrightText: 2022 CmdKit Developers
# SPDX-License-Identifier: Apache-2.0
"""Expanded logging interface built on top of the standard `logging` module."""
# type annotations
from __future__ import annotations
from typing import Dict, Tuple, Type
# standard libraries
import uuid
import socket
import datetime
import logging
# internal libs
from cmdkit.ansi import Ansi, COLOR_STDERR
# public interface
__all__ = ['Logger', 'HOSTNAME', 'INSTANCE',
'level_color', 'level_by_name',
'logging_styles', 'DEFAULT_LOGGING_STYLE',
'NOTSET', 'DEVEL', 'TRACE', 'DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL']
# Cached for later use
HOSTNAME = socket.gethostname()
HOSTNAME_SHORT = HOSTNAME.split('.', 1)[0]
# Unique for every instance of the program
INSTANCE = str(uuid.uuid4())
# Canonical colors for logging messages
level_color: Dict[str, Ansi] = {
'NULL': Ansi.NULL,
'DEVEL': Ansi.RED,
'TRACE': Ansi.CYAN,
'DEBUG': Ansi.BLUE,
'INFO': Ansi.GREEN,
'NOTICE': Ansi.CYAN,
'WARNING': Ansi.YELLOW,
'ERROR': Ansi.RED,
'CRITICAL': Ansi.MAGENTA
}
NOTICE: int = logging.INFO + 5
logging.addLevelName(NOTICE, 'NOTICE')
TRACE: int = logging.DEBUG - 5
logging.addLevelName(TRACE, 'TRACE')
DEVEL: int = 1
logging.addLevelName(DEVEL, 'DEVEL')
# Re-export for convenience
NOTSET: int = logging.NOTSET
DEBUG: int = logging.DEBUG
INFO: int = logging.INFO
WARNING: int = logging.WARNING
ERROR: int = logging.ERROR
CRITICAL: int = logging.CRITICAL
level_by_name: Dict[str, int] = {
'NOTSET': logging.NOTSET,
'DEVEL': DEVEL,
'TRACE': TRACE,
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'NOTICE': NOTICE,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL,
}
DEFAULT_LOGGING_STYLE = 'default'
logging_styles = {
'default': {
'format': ('%(ansi_bold)s%(ansi_level)s%(levelname)8s%(ansi_reset)s %(ansi_faint)s[%(name)s]%(ansi_reset)s'
' %(message)s'),
},
'system': {
'format': '%(asctime)s.%(msecs)03d %(hostname)s %(levelname)8s [%(app_id)s] [%(name)s] %(message)s',
},
'detailed': {
'format': ('%(ansi_faint)s%(asctime)s.%(msecs)03d %(hostname)s %(ansi_reset)s'
'%(ansi_level)s%(ansi_bold)s%(levelname)8s%(ansi_reset)s '
'%(ansi_faint)s[%(name)s]%(ansi_reset)s %(message)s'),
},
'detailed-compact': {
'format': ('%(ansi_faint)s%(elapsed_hms)s [%(hostname_short)s] %(ansi_reset)s'
'%(ansi_level)s%(ansi_bold)s%(levelname)8s%(ansi_reset)s '
'%(ansi_faint)s[%(relative_name)s]%(ansi_reset)s %(message)s'),
},
'short': {
'format': '[%(ansi_level)s%(levelname)s%(ansi_reset)s] %(message)s',
}
}
[docs]
class Logger(logging.Logger):
"""Extend standard `logging.Logger` with NOTICE, TRACE, and DEVEL levels."""
def notice(self, msg: str, *args, **kwargs):
"""Log 'msg % args' with severity 'NOTICE'."""
if self.isEnabledFor(NOTICE):
self._log(NOTICE, msg, args, **kwargs)
def trace(self, msg: str, *args, **kwargs):
"""Log 'msg % args' with severity 'TRACE'."""
if self.isEnabledFor(TRACE):
self._log(TRACE, msg, args, **kwargs)
def devel(self, msg: str, *args, **kwargs):
"""Log 'msg % args' with severity 'DEVEL'."""
if self.isEnabledFor(DEVEL):
self._log(DEVEL, msg, args, **kwargs)
[docs]
@classmethod
def with_name(cls: Type[Logger], name: str) -> Logger:
"""Shorthand for `log: Logger = logging.getLogger(name)`."""
return logging.getLogger(name)
[docs]
@classmethod
def default(cls: Type[Logger],
name: str = '',
format: str = logging_styles['default']['format'],
**kwargs) -> Logger:
"""Calls standard `logging.basicConfig` and returns logger."""
logging.basicConfig(format=format, **kwargs)
return cls.with_name(name)
# Register new Logger implementation
logging.setLoggerClass(Logger)
def solve_relative_time(elapsed: float) -> Tuple[float, int, datetime.timedelta, str]:
"""
Multiple formats of relative time since `elapsed` seconds.
Returns:
- Relative time in seconds (i.e., `elapsed`)
- Relative time in milliseconds
- Relative time as `datetime.timedelta`
- Relative time in dd-hh:mm:ss.sss format
"""
elapsed_ms = int(elapsed * 1000)
reltime_delta = datetime.timedelta(seconds=elapsed)
reltime_delta_hours, remainder = divmod(reltime_delta.seconds, 3600)
reltime_delta_minutes, reltime_delta_seconds = divmod(remainder, 60)
reltime_delta_milliseconds = int(reltime_delta.microseconds / 1000)
return (
elapsed,
elapsed_ms,
reltime_delta,
f'{reltime_delta.days:02d}-{reltime_delta_hours:02d}:{reltime_delta_minutes:02d}:'
f'{reltime_delta_seconds:02d}.{reltime_delta_milliseconds:03d}'
)
[docs]
class LogRecord(logging.LogRecord):
"""Extends standard `logging.LogRecord` to include ANSI colors, time formats, and other attributes."""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# Context attributes
self.app_id = INSTANCE
self.hostname = HOSTNAME
self.hostname_short = HOSTNAME_SHORT
# Guard against `logging.makeLogRecord` passing None (see Issue #20)
if isinstance(self.name, str):
self.relative_name = self.name.split('.', 1)[-1]
else:
self.relative_name = None
# Formatting attributes
self.ansi_level = level_color.get(self.levelname, Ansi.NULL).value if COLOR_STDERR else ''
self.ansi_reset = Ansi.RESET.value if COLOR_STDERR else ''
self.ansi_bold = Ansi.BOLD.value if COLOR_STDERR else ''
self.ansi_faint = Ansi.FAINT.value if COLOR_STDERR else ''
self.ansi_italic = Ansi.ITALIC.value if COLOR_STDERR else ''
self.ansi_underline = Ansi.UNDERLINE.value if COLOR_STDERR else ''
self.ansi_black = Ansi.BLACK.value if COLOR_STDERR else ''
self.ansi_red = Ansi.RED.value if COLOR_STDERR else ''
self.ansi_green = Ansi.GREEN.value if COLOR_STDERR else ''
self.ansi_yellow = Ansi.YELLOW.value if COLOR_STDERR else ''
self.ansi_blue = Ansi.BLUE.value if COLOR_STDERR else ''
self.ansi_magenta = Ansi.MAGENTA.value if COLOR_STDERR else ''
self.ansi_cyan = Ansi.CYAN.value if COLOR_STDERR else ''
self.ansi_white = Ansi.WHITE.value if COLOR_STDERR else ''
# Timing attributes
(self.elapsed,
self.elapsed_ms,
self.elapsed_delta,
self.elapsed_hms) = solve_relative_time(self.relativeCreated / 1000)
# Register new LogRecord implementation
logging.setLogRecordFactory(LogRecord)