autologging — Autologging 0.1 documentation

# Autologging 0.1 documentation

## autologging

# Source code for autologging

    # -*- coding: utf-8 -*-

    # Copyright (c) 2013 Matthew Zipay <mattz@ninthtest.net>
    #
    # Permission is hereby granted, free of charge, to any person obtaining a copy
    # of this software and associated documentation files (the "Software"), to deal
    # in the Software without restriction, including without limitation the rights
    # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    # copies of the Software, and to permit persons to whom the Software is
    # furnished to do so, subject to the following conditions:
    #
    # The above copyright notice and this permission notice shall be included in
    # all copies or substantial portions of the Software.
    #
    # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    # SOFTWARE.

    __author__ = "Matthew Zipay <mattz@ninthtest.net>"
    __version__ = "0.1"

    from functools import wraps
    import logging

    __all__ = [
        "logged",
        "TRACE",
        "traced",
        "TracedMethods",
    ]

    #: A custom tracing log level, lower in severity than :py:data:`logging.DEBUG`.
    #: Autologging :func:`traced` and :func:`TracedMethods` create log records
    #: with this custom level.
    TRACE = 1
    logging.addLevelName(TRACE, "TRACE")

    def logged(obj):
        """Add a named logger member to a decorated class.

        The logger member always has a dot-separated name consisting of the
        parent logger's name, followed by a dot ('.'), followed by the class
        name.

        If *obj* is a :py:class:`class`, then ``obj.__logger`` will have
        the logger name "module-name.class-name":

        >>> @logged
        ... class Test:
        ...     pass
        ...
        >>> Test._Test__logger.name
        'autologging.Test'

        If *obj* is a :py:class:`logging.Logger` object, then that logger is
        treated as the parent logger and the decorated class's ``__logger``
        member will have the logger name "parent-logger-name.class-name":

        >>> _logger = logging.getLogger("test.parent")
        >>> @logged(_logger)
        ... class Test:
        ...     pass
        ...
        >>> Test._Test__logger.name
        'test.parent.Test'

        .. note::

            The logger member is made "private" (i.e. ``__logger`` with
            double underscore) to ensure that log messages that include the
            *%(name)s* format placeholder are written with the correct
            name.

            Consider a subclass of a ``@logged``-decorated parent class. If
            the subclass were **not** decorated with ``@logged`` and could
            access the parent's logger member directly to make logging
            calls, those log messages would display the name of the
            **parent** class, not the subclass.

            Therefore, subclasses of a ``@logged``-decorated parent class
            that wish to use a provided ``self.__logger`` object **must**
            themselves be decorated with ``@logged``.

        """
        if (isinstance(obj, logging.Logger)):
            # decorated as `@logged(logger)' - use logger as parent
            def logged_decorator(class_):
                logger = logging.getLogger("%s.%s" % (obj.name, class_.__name__))
                setattr(class_, "_%s__logger" % class_.__name__, logger)
                #class_.__logger = logging.getLogger("%s.%s" % (obj.name,
                #                                               class_.__name__))
                return class_
            return logged_decorator
        else:
            # decorated as `@logged' - use module logger as parent
            logger = logging.getLogger("%s.%s" % (obj.__module__, obj.__name__))
            setattr(obj, "_%s__logger" % obj.__name__, logger)
            #obj.__logger = logging.getLogger("%s.%s" % (obj.__module__,
            #                                            obj.__name__))
            return obj

    def traced(obj):
        """Add call/return tracing to an unbound function.

        .. warning::

            This decorator will not quite work as expected (or may fail
            entirely) for class methods. To automatically trace class
            method call/return, please see the :class:`TracedMethods`
            metaclass factory.

        In the following example, tracing log messages are written to a
        log whose channel defaults to the function's module name:

        >>> import sys
        >>> logging.basicConfig(level=TRACE, stream=sys.stdout)
        >>> @traced
        ... def my_function(arg, keyword=None):
        ...     return "%s and %s" % (arg, keyword)
        ...
        >>> my_function("spam", keyword="eggs")
        TRACE:autologging:CALL my_function *('spam',) **{'keyword': 'eggs'}
        TRACE:autologging:RETURN my_function 'spam and eggs'
        'spam and eggs'

        In the following example, tracing log messages are written to
        a user-named log:

        >>> import sys
        >>> logging.basicConfig(level=TRACE, stream=sys.stdout)
        >>> _logger = logging.getLogger("test.ing")
        >>> @traced(_logger)
        ... def my_function(arg, keyword=None):
        ...     return "%s and %s" % (arg, keyword)
        ...
        >>> my_function("spam", keyword="eggs")
        TRACE:test.ing:CALL my_function *('spam',) **{'keyword': 'eggs'}
        TRACE:test.ing:RETURN my_function 'spam and eggs'
        'spam and eggs'

        """
        if (isinstance(obj, logging.Logger)):
            # decorated as `@traced(logger)' - log to logger
            logger = obj
            def traced_decorator(function):
                return _make_function_tracer(function, logger)
            return traced_decorator
        else:
            # decorated as `@traced' - log to module logger
            function = obj
            logger = logging.getLogger(function.__module__)
            return _make_function_tracer(function, logger)

    def _make_function_tracer(function, logger):
        """Return a tracing proxy function for *function*.

        *function* is assumed to be an unbound, module-level function.

        *logger* is a :py:class:`logging.Logger` object to which tracing
        messages will be sent.

        """
        logger_proxy = _LoggerCallerProxy(logger, function)
        @wraps(function)
        def autologging_function_trace(*args, **keywords):
            if (logger_proxy.isEnabledFor(TRACE)):
                logger_proxy.autologging_call("%s *%r **%r", function.__name__,
                                              args, keywords)
                value = function(*args, **keywords)
                logger_proxy.autologging_return("%s %r", function.__name__, value)
                return value
            else:
                return function(*args, **keywords)
        return autologging_function_trace

    def TracedMethods(*args):
        """Return a metaclass that enables call/return tracing for methods.

        Only methods named **explicitly** in *args* will be traced.

        If the **first** item in *args* is a :py:class:`logging.Logger`,
        then that logger is treated as the parent logger, and method tracers
        will use a logger with the name "parent-logger-name.class-name".
        Otherwise, method tracers will use a logger with the name
        "module-name.class-name".

        .. note::

            Regardless of whether a logger was explicitly passed in or not,
            the logger used by the tracers is **not** made available to the
            class or its instances.

            This allows for logging configurations where tracing is sent to
            a separate file/target than logging. For such a configuration,
            simply configure a logger specifically for tracing and pass that
            logger as the first argument to ``TracedMethods``.

            Also note that a class may be decorated by :func:`logged`
            **and** use ``TracedMethods`` as a metaclass without conflict.

        In the following example, tracing log messages are written to a
        log whose channel defaults to "module-name.class-name":

        >>> import sys
        >>> logging.basicConfig(level=TRACE, stream=sys.stdout)
        >>> class MyClass(object,
        ...               metaclass=TracedMethods("my_staticmethod",
        ...                                       "my_classmethod",
        ...                                       "my_instancemethod")):
        ...     @staticmethod
        ...     def my_staticmethod(arg, keyword=None):
        ...         return "%s and %s" % (arg, keyword)
        ...     @classmethod
        ...     def my_classmethod(cls, arg, keyword=None):
        ...         return "%s and %s" % (arg, keyword)
        ...     def my_instancemethod(self, arg, keyword=None):
        ...         return "%s and %s" % (arg, keyword)
        ...
        >>> MyClass.my_staticmethod("spam", keyword="eggs")
        TRACE:autologging.MyClass:CALL MyClass.my_staticmethod *('spam',) **{'keyword': 'eggs'}
        TRACE:autologging.MyClass:RETURN MyClass.my_staticmethod 'spam and eggs'
        'spam and eggs'
        >>> MyClass.my_classmethod("green eggs", keyword="ham")
        TRACE:autologging.MyClass:CALL MyClass.my_classmethod *('green eggs',) **{'keyword': 'ham'}
        TRACE:autologging.MyClass:RETURN MyClass.my_classmethod 'green eggs and ham'
        'green eggs and ham'
        >>> instance = MyClass()
        >>> instance.my_instancemethod("Batman", keyword="Robin")
        TRACE:autologging.MyClass:CALL MyClass.my_instancemethod *('Batman',) **{'keyword': 'Robin'}
        TRACE:autologging.MyClass:RETURN MyClass.my_instancemethod 'Batman and Robin'
        'Batman and Robin'

        In the following example, tracing log messages are written to
        a user-named log:

        >>> import sys
        >>> logging.basicConfig(level=TRACE, stream=sys.stdout)
        >>> _logger = logging.getLogger("test.ing")
        >>> class MyClass(object,
        ...               metaclass=TracedMethods(_logger, "my_staticmethod",
        ...                                       "my_classmethod",
        ...                                       "my_instancemethod")):
        ...     @staticmethod
        ...     def my_staticmethod(arg, keyword=None):
        ...         return "%s and %s" % (arg, keyword)
        ...     @classmethod
        ...     def my_classmethod(cls, arg, keyword=None):
        ...         return "%s and %s" % (arg, keyword)
        ...     def my_instancemethod(self, arg, keyword=None):
        ...         return "%s and %s" % (arg, keyword)
        ...
        >>> MyClass.my_staticmethod("spam", keyword="eggs")
        TRACE:test.ing.MyClass:CALL MyClass.my_staticmethod *('spam',) **{'keyword': 'eggs'}
        TRACE:test.ing.MyClass:RETURN MyClass.my_staticmethod 'spam and eggs'
        'spam and eggs'
        >>> MyClass.my_classmethod("green eggs", keyword="ham")
        TRACE:test.ing.MyClass:CALL MyClass.my_classmethod *('green eggs',) **{'keyword': 'ham'}
        TRACE:test.ing.MyClass:RETURN MyClass.my_classmethod 'green eggs and ham'
        'green eggs and ham'
        >>> instance = MyClass()
        >>> instance.my_instancemethod("Batman", keyword="Robin")
        TRACE:test.ing.MyClass:CALL MyClass.my_instancemethod *('Batman',) **{'keyword': 'Robin'}
        TRACE:test.ing.MyClass:RETURN MyClass.my_instancemethod 'Batman and Robin'
        'Batman and Robin'

        """
        class TracingMeta(type):
            def __new__(meta, name, bases, dict_):
                if (args and isinstance(args[0], logging.Logger)):
                    logger = logging.getLogger("%s.%s" % (args[0].name, name))
                    method_names = args[1:]
                else:
                    logger = logging.getLogger("%s.%s" %
                                               (dict_["__module__"], name))
                    method_names = args

                # replace each named method with a tracing proxy method
                for method_name in method_names:
                    dict_[method_name] = _make_method_tracer(name,
                                                             dict_[method_name],
                                                             logger)
                return super(TracingMeta, meta).__new__(meta, name, bases, dict_)
        return TracingMeta

    def _make_method_tracer(classname, method, logger):
        """Return a tracing proxy method for *method*.

        *method* can be an instance method, :py:class:`classmethod`, or
        :py:class:`staticmethod`.

        *logger* is a :py:class:`logging.Logger` object to which tracing
        messages will be sent.

        """
        if (type(method) is staticmethod):
            make_tracer = _make_staticmethod_tracer
        elif (type(method) is classmethod):
            make_tracer = _make_classmethod_tracer
        else:
            make_tracer = _make_instancemethod_tracer
        return make_tracer(classname, method, logger)

    def _make_instancemethod_tracer(classname, method, logger):
        """Return a tracing proxy instance method for *method*.

        *classname* is the simple name of the class to which *method*
        belongs.

        *method* must be an instance method of the class named by
        *classname*.

        *logger* is a :py:class:`logging.Logger` object to which tracing
        messages will be sent.

        """
        logger_proxy = _LoggerCallerProxy(logger, method)
        dotted_name = "%s.%s" % (classname, method.__name__)
        @wraps(method)
        def autologging_instancemethod_trace(self, *args, **keywords):
            if (logger_proxy.isEnabledFor(TRACE)):
                logger_proxy.autologging_call("%s *%r **%r",
                                              dotted_name, args, keywords)
                value = method(self, *args, **keywords)
                logger_proxy.autologging_return("%s %r", dotted_name, value)
                return value
            else:
                return method(self, *args, **keywords)
        return autologging_instancemethod_trace

    def _make_classmethod_tracer(classname, descriptor, logger):
        """Return a tracing proxy :py:class:`classmethod` for *descriptor*.

        *classname* is the simple name of the class to which *descriptor*
        belongs.

        *descriptor* must be a ``classmethod`` descriptor of the class named
        by *classname*.

        *logger* is a :py:class:`logging.Logger` object to which tracing
        messages will be sent.

        """
        function = descriptor.__get__(None, descriptor)
        logger_proxy = _LoggerCallerProxy(logger, function)
        dotted_name = "%s.%s" % (classname, function.__name__)
        @wraps(function)
        def autologging_classmethod_trace(cls, *args, **keywords):
            if (logger_proxy.isEnabledFor(TRACE)):
                logger_proxy.autologging_call("%s *%r **%r",
                                              dotted_name, args, keywords)
                value = function(*args, **keywords)
                logger_proxy.autologging_return("%s %r", dotted_name, value)
                return value
            else:
                return function(*args, **keywords)
        return classmethod(autologging_classmethod_trace)

    def _make_staticmethod_tracer(classname, descriptor, logger):
        """Return a tracing proxy :py:class:`staticmethod` for *descriptor*.

        *classname* is the simple name of the class to which *descriptor*
        belongs.

        *descriptor* must be a ``staticmethod`` descriptor of the class
        named by *classname*.

        *logger* is a :py:class:`logging.Logger` object to which tracing
        messages will be sent.

        """
        function = descriptor.__get__(None, descriptor)
        logger_proxy = _LoggerCallerProxy(logger, function)
        dotted_name = "%s.%s" % (classname, function.__name__)
        @wraps(function)
        def autologging_staticmethod_trace(*args, **keywords):
            if (logger_proxy.isEnabledFor(TRACE)):
                logger_proxy.autologging_call("%s *%r **%r",
                                              dotted_name, args, keywords)
                value = function(*args, **keywords)
                logger_proxy.autologging_return("%s %r", dotted_name, value)
                return value
            else:
                return function(*args, **keywords)
        return staticmethod(autologging_staticmethod_trace)

    class _LoggerCallerProxy(logging.getLoggerClass()):
        """A subclass that uses caller information from a proxied function.

        .. note::

            Without this subclass, tracing log messages would report caller
            information (filename, function name, etc.) of the proxy
            function rather than of the prox**ied** function.

        """

        def __init__(self, logger, proxied_function):
            """Impersonate *logger* and extract caller info from
            *proxied_function*.

            """
            super(_LoggerCallerProxy, self).__init__(logger.name,
                                                     level=logger.level)
            self.__dict__.update(logger.__dict__)

            # __code__ was backported to 2.7, but just to be safe...
            code = getattr(proxied_function, "func_code",
                           getattr(proxied_function, "__code__"))
            self._autologging_co_filename = code.co_filename
            self._autologging_co_firstlineno = code.co_firstlineno
            self._autologging_co_lastlineno = _find_last_line_number(code)
            self._autologging_co_name = code.co_name
            self.findCaller = self.autologging_findCaller

        def autologging_call(self, msg, *args, **kwargs):
            """Log a :data:`TRACE`-level message that a function is called.

            The *msg*, *args*, and *kwargs* arguments are the same as for
            :py:func:`logging.Logger.log` (but note that the literal string
            "CALL " will be automatically prepended to *msg*).

            """
            if (self.isEnabledFor(TRACE)):
                msg = "CALL " + msg
                self._autologging_f_lineno = self._autologging_co_firstlineno
                super(_LoggerCallerProxy, self).log(TRACE, msg, *args, **kwargs)

        def autologging_return(self, msg, *args, **kwargs):
            """Log a :data:`TRACE`-level message that a function returned.

            The *msg*, *args*, and *kwargs* arguments are the same as for
            :py:func:`logging.Logger.log` (but note that the literal string
            "RETURN " will be automatically prepended to *msg*).

            """
            if (self.isEnabledFor(TRACE)):
                msg = "RETURN " + msg
                self._autologging_f_lineno = self._autologging_co_lastlineno
                super(_LoggerCallerProxy, self).log(TRACE, msg, *args, **kwargs)

        def autologging_findCaller(self, *args, **keywords):
            """Return caller information used to construct a
            :py:class:`logging.LogRecord`.

            This method replaces :py:func:`logging.Logger.findCaller` to
            provide the filename, line number, and function name of a
            proxied function. (This information would be otherwise
            unavailable to a logger because it is inaccessible from
            stack frames.)

            """
            # keyword 'stack_info' was added in Python 3.2
            rv = super(_LoggerCallerProxy, self).findCaller(*args, **keywords)
            return (self._autologging_co_filename, self._autologging_f_lineno,
                    self._autologging_co_name) + rv[3:]

    def _find_last_line_number(func_code):
        """Return the last line number of a function.

        *func_code* is a :py:class:`types.CodeType` object (as obtained from
        ``some_function.__code__`` or ``some_function.func_code``).

        """
        last_line_number = func_code.co_firstlineno
        # co_lnotab is a sequence of 2-byte offsets (address offset, line number
        # offset), each relative to the previous; we only care about the line
        # number offsets here, so start at index 1 and increment by 2
        i = 1
        if (isinstance(func_code.co_lnotab, str)):
            # co_lnotab is str in Python 2
            while (i < len(func_code.co_lnotab)):
                last_line_number += ord(func_code.co_lnotab[i])
                i += 2
        else:
            # co_lnotab is bytes in Python 3
            while (i < len(func_code.co_lnotab)):
                last_line_number += func_code.co_lnotab[i]
                i += 2
        return last_line_number