diff --git a/README.md b/README.md index 6019462..6a5ed47 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,56 @@ # syslogformat -Python logging.Formatter class for syslog style messages +Python `logging.Formatter` class for syslog style messages ## Usage -... +Example with in-code setup: +```python +# example.py +import logging +from syslogformat import SyslogFormatter + +log = logging.getLogger() +hdl = logging.StreamHandler() +fmt = SyslogFormatter() +hdl.setFormatter(fmt) +hdl.setLevel(logging.NOTSET) +log.addHandler(hdl) +log.setLevel(logging.NOTSET) + +log.debug("foo") +log.info("bar") +log.warning("baz") +try: + raise ValueError("this is bad") +except ValueError as e: + log.exception("oh no") +``` + +This is what the output should be like: +``` +<7>DEBUG | foo +<6>INFO | bar +<4>WARNING | baz | example..14 +<3>ERROR | oh no | example..18 | 'Traceback (most recent call last):\n File "/path/to/example.py", line 16, in \n raise ValueError("this is bad")\nValueError: this is bad' +``` + +Same configuration with YAML file to be loaded into `logging.config.dictConfig`: +```yaml +version: 1 +formatters: + custom: + (): syslogformat.SyslogFormatter +handlers: + console: + class: logging.StreamHandler + level: NOTSET + formatter: custom + stream: ext://sys.stdout +root: + level: NOTSET + handlers: [console] +``` ## Installation @@ -12,7 +58,7 @@ Python logging.Formatter class for syslog style messages ## Dependencies -Python Version ..., OS ... +Python Version 3 ## Building from source diff --git a/src/package_name/__init__.py b/src/package_name/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/syslogformat/__init__.py b/src/syslogformat/__init__.py new file mode 100644 index 0000000..d16644d --- /dev/null +++ b/src/syslogformat/__init__.py @@ -0,0 +1 @@ +from syslogformat.formatter import SyslogFormatter diff --git a/src/syslogformat/formatter.py b/src/syslogformat/formatter.py new file mode 100644 index 0000000..3a1f25b --- /dev/null +++ b/src/syslogformat/formatter.py @@ -0,0 +1,65 @@ +import logging as _logging + +from syslogformat.helpers import check_level, py_to_sys_lvl + + +class SyslogFormatter(_logging.Formatter): + """ + logging.Formatter subclass for doing three things: + 1) Format exception log messages into one-liners, + 2) prepend a syslog oriented log level number to be recognized by systemd, and + 3) append more details ([module].[function].[line]) to every message, when specified level is exceeded. + """ + def __init__(self, *args, **kwargs): + self.detail_threshold = check_level(kwargs.pop('detail_threshold', _logging.WARNING)) + self.prepend_lvl_name = check_level(kwargs.pop('prepend_lvl_name', True)) + super().__init__(*args, **kwargs) + + def formatException(self, exc_info) -> str: + """Format an exception so that it prints on a single line.""" + return repr(super().formatException(exc_info)) + + def format(self, record: _logging.LogRecord) -> str: + """ + Format to be compatible with syslog levels and replace the newlines. + The entire message format is hard-coded here, so no format needs to be specified in the usual config. + """ + record.message = record.getMessage() + + # Prepend syslog level depending on record level + s = f"<{py_to_sys_lvl(record.levelno)}>" + if self.prepend_lvl_name: + s += f"{record.levelname:<8} | " + s += self.formatMessage(record) + + # If record level exceeds the threshold, append additional details + if record.levelno >= self.detail_threshold: + s += f" | {record.module}.{record.funcName}.{record.lineno}" + + if record.exc_info and not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + if record.exc_text: + s += ' | ' + record.exc_text + if record.stack_info: + s += self.formatStack(record.stack_info) + + # Reformat exception line + return s.replace("\n", "") + + +if __name__ == '__main__': + + log = _logging.getLogger() + hdl = _logging.StreamHandler() + fmt = SyslogFormatter() + hdl.setFormatter(fmt) + log.addHandler(hdl) + log.setLevel(_logging.NOTSET) + + log.debug("foo") + log.info("bar") + log.warning("baz") + try: + raise ValueError("this is bad") + except ValueError as e: + log.exception("oh no") diff --git a/src/syslogformat/helpers.py b/src/syslogformat/helpers.py new file mode 100644 index 0000000..5ee24af --- /dev/null +++ b/src/syslogformat/helpers.py @@ -0,0 +1,54 @@ +import logging as _logging +from syslog import LOG_ALERT, LOG_CRIT, LOG_ERR, LOG_WARNING, LOG_INFO, LOG_DEBUG +from typing import Union + + +LevelT = Union[str, int] + + +def check_level(level: LevelT) -> int: + """ + Custom implementation of the logging module's _checkLevel(...) function. + Returns the numeric representation of a log level. + + Args: + level: Either a string such as 'DEBUG' or 'WARNING' or an integer. + + Returns: + If an integer is passed, it is returned unchanged; + if a string is passed, the corresponding numeric log level is returned. + + Raises: + TypeError if something other than a string or an integer is passed. + ValueError if the string has no corresponding level in the logging module. + """ + if isinstance(level, int): + return level + if str(level) != level: + raise TypeError(f"Level not an integer or a valid string: {level}") + output = getattr(_logging, level) + if output is None: + raise ValueError(f"Unknown level: {level}") + return output + + +def py_to_sys_lvl(level_num: int) -> int: + """ + Maps a (numeric) log level as defined in Python stdlib logging module to the syslog module's log levels. + The output number corresponds to a syslog `PRI` without the enclosing angle brackets. + Even though there are more levels available to syslog, the `EMERG` (num. 0) and `NOTICE` (num. 5) levels are + omitted here, i.e. it goes straight from `INFO` (num. 6) to `WARNING` (num. 4) because there is no equivalent + in the Python logging module to `NOTICE`, and `EMERG` is unnecessary because no Python script should be able to + cause such severe problems. + """ + if level_num <= _logging.DEBUG: + return LOG_DEBUG + if level_num <= _logging.INFO: + return LOG_INFO + if level_num <= _logging.WARNING: + return LOG_WARNING + if level_num <= _logging.ERROR: + return LOG_ERR + if level_num <= _logging.CRITICAL: + return LOG_CRIT + return LOG_ALERT