From 32f32915b1716a11b4a379d186176e796e902a42 Mon Sep 17 00:00:00 2001 From: Peter Odding Date: Fri, 13 Nov 2015 00:12:55 +0100 Subject: [PATCH 1/2] For consistency, before anyone notices ;-) --- coloredlogs/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coloredlogs/__init__.py b/coloredlogs/__init__.py index 4a9d682..b402398 100644 --- a/coloredlogs/__init__.py +++ b/coloredlogs/__init__.py @@ -1,7 +1,7 @@ # Colored terminal output for Python's logging module. # # Author: Peter Odding -# Last Change: November 12, 2015 +# Last Change: November 13, 2015 # URL: https://coloredlogs.readthedocs.org """ @@ -749,7 +749,7 @@ class ProgramNameFilter(logging.Filter): """ @classmethod - def install(cls, fmt, handler, programname=None): + def install(cls, handler, fmt, programname=None): """ Install the :class:`ProgramNameFilter` (only if needed). From a2f6254b6b6aba87c93fa1362ef0509a143a484c Mon Sep 17 00:00:00 2001 From: Peter Odding Date: Fri, 13 Nov 2015 01:19:55 +0100 Subject: [PATCH 2/2] Easy to use UNIX system logging?! --- coloredlogs/__init__.py | 2 +- coloredlogs/syslog.py | 198 ++++++++++++++++++++++++++++++++++++++++ coloredlogs/tests.py | 19 +++- docs/index.rst | 12 +++ 4 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 coloredlogs/syslog.py diff --git a/coloredlogs/__init__.py b/coloredlogs/__init__.py index b402398..e65a71f 100644 --- a/coloredlogs/__init__.py +++ b/coloredlogs/__init__.py @@ -103,7 +103,7 @@ """ # Semi-standard module versioning. -__version__ = '3.2' +__version__ = '3.3' # Standard library modules. import collections diff --git a/coloredlogs/syslog.py b/coloredlogs/syslog.py new file mode 100644 index 0000000..2752937 --- /dev/null +++ b/coloredlogs/syslog.py @@ -0,0 +1,198 @@ +# Easy to use system logging for Python's logging module. +# +# Author: Peter Odding +# Last Change: November 13, 2015 +# URL: https://coloredlogs.readthedocs.org + +""" +Easy to use UNIX system logging for Python's :mod:`logging` module. + +Admittedly system logging has little to do with colored terminal output, however: + +- The `coloredlogs` package is my attempt to do Python logging right and system + logging is an important part of that equation. + +- I've seen a surprising number of quirks and mistakes in system logging done + in Python, for example including ``%(asctime)s`` in a format string (the + system logging daemon is responsible for adding timestamps and thus you end + up with duplicate timestamps that make the logs awful to read :-). + +- The ``%(programname)s`` filter originated in my system logging code and I + wanted it in `coloredlogs` so the step to include this module wasn't that big. + +- As a bonus this Python module now has a test suite and proper documentation. + +So there :-P. Go take a look at :func:`enable_system_logging()`. +""" + +# Standard library modules. +import logging +import logging.handlers +import os +import socket +import sys + +# Modules included in our package. +from coloredlogs import ProgramNameFilter, find_program_name + +LOG_DEVICE_MACOSX = '/var/run/syslog' +"""The pathname of the log device on Mac OS X (a string).""" + +LOG_DEVICE_UNIX = '/dev/log' +"""The pathname of the log device on Linux and most other UNIX systems (a string).""" + +DEFAULT_LOG_FORMAT = '%(programname)s[%(process)d]: %(levelname)s %(message)s' +""" +The default format for log messages sent to the system log (a string). + +The ``%(programname)s`` format requires :class:`~coloredlogs.ProgramNameFilter` +but :func:`enable_system_logging()` takes care of this for you. + +The ``name[pid]:`` construct (specifically the colon) in the format allows +rsyslogd_ to extract the ``$programname`` from each log message, which in turn +allows configuration files in ``/etc/rsyslog.d/*.conf`` to filter these log +messages to a separate log file (if the need arises). + +.. _rsyslogd: https://en.wikipedia.org/wiki/Rsyslog +""" + +# Initialize a logger for this module. +logger = logging.getLogger(__name__) + + +class SystemLogging(object): + + """Context manager to enable system logging.""" + + def __init__(self, *args, **kw): + """ + Initialize a :class:`SystemLogging` object. + + :param args: Positional arguments to :func:`enable_system_logging()`. + :param kw: Keyword arguments to :func:`enable_system_logging()`. + """ + self.args = args + self.kw = kw + self.silent = kw.pop('silent', False) + self.handler = None + + def __enter__(self): + """Enable system logging when entering the context.""" + if self.handler is None: + self.handler = enable_system_logging(*self.args, **self.kw) + return self.handler + + def __exit__(self, exc_type=None, exc_value=None, traceback=None): + """ + Disable system logging when leaving the context. + + .. note:: If an exception is being handled when we leave the context a + warning message including traceback is logged *before* system + logging is disabled. + """ + if self.handler is not None: + if exc_type is not None: + logger.warning("Disabling system logging due to unhandled exception!", exc_info=True) + (self.kw.get('logger') or logging.getLogger()).removeHandler(self.handler) + self.handler = None + + +def enable_system_logging(programname=None, **kw): + """ + Redirect :mod:`logging` messages to the system log (e.g. ``/var/log/syslog``). + + :param programname: The program name embedded in log messages (a string, defaults + to the result of :func:`~coloredlogs.find_program_name()`). + :param logger: The logger to which the :class:`~logging.handlers.SysLogHandler` + should be connected (defaults to the root logger). + :param kw: Refer to :func:`connect_to_syslog()`. + :returns: A :class:`~logging.handlers.SysLogHandler` object or :data:`None` (if the + system logging daemon is unavailable). + """ + # Remove the keyword arguments we handle. + programname = programname or find_program_name() + logger = kw.pop('logger', None) or logging.getLogger() + fmt = kw.pop('fmt', None) or DEFAULT_LOG_FORMAT + # Delegate the remaining keyword arguments to connect_to_syslog(). + handler = connect_to_syslog(**kw) + # Make sure a handler was successfully created. + if handler: + # Enable the use of %(programname)s. + ProgramNameFilter.install( + handler=handler, + fmt=fmt, + programname=programname, + ) + # Connect the formatter, handler and logger. + handler.setFormatter(logging.Formatter(fmt)) + logger.addHandler(handler) + return handler + + +def connect_to_syslog(address=None, facility=None, level=None): + """ + Create a :class:`~logging.handlers.SysLogHandler`. + + :param address: The device file or network address of the system logging + daemon (a string or tuple, defaults to the result of + :func:`find_syslog_address()`). + :param facility: Refer to :class:`~logging.handlers.SysLogHandler`. + :param level: The logging level for the :class:`~logging.handlers.SysLogHandler` + (defaults to :data:`logging.DEBUG` meaning nothing is filtered). + :returns: A :class:`~logging.handlers.SysLogHandler` object or :data:`None` (if the + system logging daemon is unavailable). + + The process of connecting to the system logging daemon goes as follows: + + - If :class:`~logging.handlers.SysLogHandler` supports the `socktype` + option (it does since Python 2.7) the following two socket types are + tried (in decreasing preference): + + 1. :data:`~socket.SOCK_RAW` avoids truncation of log messages but may + not be supported. + 2. :data:`~socket.SOCK_STREAM` (TCP) supports longer messages than the + default (which is UDP). + + - If socket types are not supported Python's (2.6) defaults are used to + connect to the given `address`. + """ + if not address: + address = find_syslog_address() + if facility is None: + facility = logging.handlers.SysLogHandler.LOG_USER + if level is None: + level = logging.DEBUG + for socktype in socket.SOCK_RAW, socket.SOCK_STREAM, None: + kw = dict(facility=facility, address=address) + if socktype: + kw['socktype'] = socktype + try: + handler = logging.handlers.SysLogHandler(**kw) + except (IOError, TypeError): + # The socktype argument was added in Python 2.7 and its use will raise a + # TypeError exception on Python 2.6. IOError is a superclass of socket.error + # (since Python 2.6) which can be raised if the system logging daemon is + # unavailable. + pass + else: + handler.setLevel(level) + return handler + + +def find_syslog_address(): + """ + Find the most suitable destination for system log messages. + + :returns: The pathname of a log device (a string) or an address/port tuple as + supported by :class:`~logging.handlers.SysLogHandler`. + + On Mac OS X this prefers :data:`LOG_DEVICE_MACOSX`, after that :data:`LOG_DEVICE_UNIX` + is checked for existence. If both of these device files don't exist the default used + by :class:`~logging.handlers.SysLogHandler` is returned. + """ + if sys.platform == 'darwin' and os.path.exists(LOG_DEVICE_MACOSX): + return LOG_DEVICE_MACOSX + elif os.path.exists(LOG_DEVICE_UNIX): + return LOG_DEVICE_UNIX + else: + return 'localhost', logging.handlers.SYSLOG_UDP_PORT diff --git a/coloredlogs/tests.py b/coloredlogs/tests.py index bece57c..724e8ce 100644 --- a/coloredlogs/tests.py +++ b/coloredlogs/tests.py @@ -1,7 +1,7 @@ # Automated tests for the `coloredlogs' package. # # Author: Peter Odding -# Last Change: November 12, 2015 +# Last Change: November 13, 2015 # URL: https://coloredlogs.readthedocs.org """Automated tests for the `coloredlogs` package.""" @@ -23,6 +23,7 @@ import coloredlogs import coloredlogs.cli import coloredlogs.converter +import coloredlogs.syslog # External test dependencies. from capturer import CaptureOutput @@ -54,7 +55,7 @@ def setUp(self): # Reset local state. self.stream = StringIO() self.handler = coloredlogs.ColoredStreamHandler(stream=self.stream, isatty=False) - self.logger_name = ''.join(random.choice(string.ascii_letters) for i in range(25)) + self.logger_name = random_string(25) self.logger = VerboseLogger(self.logger_name) self.logger.addHandler(self.handler) # Speed up the tests by disabling the demo's artificial delay. @@ -104,6 +105,15 @@ def test_program_name_filter(self): output = capturer.get_text() assert coloredlogs.find_program_name() in output + def test_system_logging(self): + """Make sure the :mod:`coloredlogs.syslog` module works.""" + expected_message = random_string(50) + with coloredlogs.syslog.SystemLogging(programname='coloredlogs-test-suite') as syslog: + logging.info("%s", expected_message) + if syslog and os.path.isfile('/var/log/syslog'): + with open('/var/log/syslog') as handle: + assert any(expected_message in line for line in handle) + def test_name_normalization(self): """Make sure :class:`~coloredlogs.NameNormalizer` works as intended.""" nn = coloredlogs.NameNormalizer() @@ -253,3 +263,8 @@ def main(*arguments, **options): finally: sys.argv = saved_argv sys.stdout = saved_stdout + + +def random_string(length=25): + """Generate a random string.""" + return ''.join(random.choice(string.ascii_letters) for i in range(25)) diff --git a/docs/index.rst b/docs/index.rst index b44bc8f..b4e21b2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,12 @@ API documentation The following documentation is based on the source code of version |release| of the `coloredlogs` package. +The most useful entry points into the documentation are: + +- :func:`coloredlogs.install()` +- :class:`coloredlogs.ColoredFormatter` +- :func:`~coloredlogs.syslog.enable_system_logging()` + The :mod:`coloredlogs` module ----------------------------- @@ -17,3 +23,9 @@ The :mod:`coloredlogs.converter` module .. automodule:: coloredlogs.converter :members: + +The :mod:`coloredlogs.syslog` module +------------------------------------ + +.. automodule:: coloredlogs.syslog + :members: