summaryrefslogtreecommitdiff
path: root/pytest_catchlog.py
diff options
context:
space:
mode:
Diffstat (limited to 'pytest_catchlog.py')
-rw-r--r--pytest_catchlog.py287
1 files changed, 197 insertions, 90 deletions
diff --git a/pytest_catchlog.py b/pytest_catchlog.py
index 61cdb94..892fd68 100644
--- a/pytest_catchlog.py
+++ b/pytest_catchlog.py
@@ -1,30 +1,104 @@
# -*- coding: utf-8 -*-
-from __future__ import (absolute_import, print_function,
- unicode_literals, division)
+from __future__ import absolute_import, division, print_function
+import functools
import logging
+from contextlib import closing, contextmanager
+import pytest
import py
-__version__ = '1.1'
+__version__ = '1.2.1'
+
+
+def get_logger_obj(logger=None):
+ """Get a logger object that can be specified by its name, or passed as is.
+
+ Defaults to the root logger.
+ """
+ if logger is None or isinstance(logger, py.builtin._basestring):
+ logger = logging.getLogger(logger)
+ return logger
+
+
+@contextmanager
+def logging_at_level(level, logger=None):
+ """Context manager that sets the level for capturing of logs."""
+ logger = get_logger_obj(logger)
+
+ orig_level = logger.level
+ logger.setLevel(level)
+ try:
+ yield
+ finally:
+ logger.setLevel(orig_level)
+
+
+@contextmanager
+def logging_using_handler(handler, logger=None):
+ """Context manager that safely register a given handler."""
+ logger = get_logger_obj(logger)
+
+ if handler in logger.handlers: # reentrancy
+ # Adding the same handler twice would confuse logging system.
+ # Just don't do that.
+ yield
+ else:
+ logger.addHandler(handler)
+ try:
+ yield
+ finally:
+ logger.removeHandler(handler)
+
+
+@contextmanager
+def catching_logs(handler, filter=None, formatter=None,
+ level=logging.NOTSET, logger=None):
+ """Context manager that prepares the whole logging machinery properly."""
+ logger = get_logger_obj(logger)
+
+ if filter is not None:
+ handler.addFilter(filter)
+ if formatter is not None:
+ handler.setFormatter(formatter)
+ handler.setLevel(level)
+
+ with closing(handler):
+ with logging_using_handler(handler, logger):
+ with logging_at_level(min(handler.level, logger.level), logger):
+
+ yield handler
+
+
+def add_option_ini(parser, option, dest, default=None, help=None):
+ parser.addini(dest, default=default,
+ help='default value for ' + option)
+ parser.getgroup('catchlog').addoption(option, dest=dest, help=help)
+
+def get_option_ini(config, name):
+ ret = config.getoption(name) # 'default' arg won't work as expected
+ if ret is None:
+ ret = config.getini(name)
+ return ret
def pytest_addoption(parser):
"""Add options to control log capturing."""
- group = parser.getgroup('catchlog', 'Log catching.')
+ group = parser.getgroup('catchlog', 'Log catching')
group.addoption('--no-print-logs',
dest='log_print', action='store_false', default=True,
help='disable printing caught logs on failed tests.')
- group.addoption('--log-format',
- dest='log_format',
- default=('%(filename)-25s %(lineno)4d'
- ' %(levelname)-8s %(message)s'),
- help='log format as used by the logging module.')
- group.addoption('--log-date-format',
- dest='log_date_format', default=None,
- help='log date format as used by the logging module.')
+ add_option_ini(parser,
+ '--log-format',
+ dest='log_format', default=('%(filename)-25s %(lineno)4d'
+ ' %(levelname)-8s %(message)s'),
+ help='log format as used by the logging module.')
+ add_option_ini(parser,
+ '--log-date-format',
+ dest='log_date_format', default=None,
+ help='log date format as used by the logging module.')
def pytest_configure(config):
@@ -44,62 +118,44 @@ class CatchLogPlugin(object):
The formatter can be safely shared across all handlers so
create a single one for the entire test session here.
"""
- self.print_logs = config.getvalue('log_print')
- self.formatter = logging.Formatter(config.getvalue('log_format'),
- config.getvalue('log_date_format'))
-
+ self.print_logs = config.getoption('log_print')
+ self.formatter = logging.Formatter(
+ get_option_ini(config, 'log_format'),
+ get_option_ini(config, 'log_date_format'))
+
+ @contextmanager
+ def _runtest_for(self, item, when):
+ """Implements the internals of pytest_runtest_xxx() hook."""
+ with catching_logs(LogCaptureHandler(),
+ formatter=self.formatter) as log_handler:
+ item.catch_log_handler = log_handler
+ try:
+ yield # run test
+ finally:
+ del item.catch_log_handler
+
+ if self.print_logs:
+ # Add a captured log section to the report.
+ log = log_handler.stream.getvalue().strip()
+ item.add_report_section(when, 'log', log)
+
+ @pytest.mark.hookwrapper
def pytest_runtest_setup(self, item):
- """Start capturing log messages for this test.
-
- Creating a specific handler for each test ensures that we
- avoid multi threading issues.
-
- Attaching the handler and setting the level at the beginning
- of each test ensures that we are setup to capture log
- messages.
- """
+ with self._runtest_for(item, 'setup'):
+ yield
- # Create a handler for this test.
- item.catch_log_handler = CatchLogHandler()
- item.catch_log_handler.setFormatter(self.formatter)
+ @pytest.mark.hookwrapper
+ def pytest_runtest_call(self, item):
+ with self._runtest_for(item, 'call'):
+ yield
- # Attach the handler to the root logger and ensure that the
- # root logger is set to log all levels.
- root_logger = logging.getLogger()
- root_logger.addHandler(item.catch_log_handler)
- root_logger.setLevel(logging.NOTSET)
+ @pytest.mark.hookwrapper
+ def pytest_runtest_teardown(self, item):
+ with self._runtest_for(item, 'teardown'):
+ yield
- def pytest_runtest_makereport(self, __multicall__, item, call):
- """Add captured log messages for this report."""
- report = __multicall__.execute()
-
- # This fn called after setup, call and teardown. Only
- # interested in just after test call has finished.
- if call.when == 'call':
-
- # Detach the handler from the root logger to ensure no
- # further access to the handler.
- root_logger = logging.getLogger()
- root_logger.removeHandler(item.catch_log_handler)
-
- # For failed tests that have captured log messages add a
- # captured log section to the report if desired.
- if not report.passed and self.print_logs:
- long_repr = getattr(report, 'longrepr', None)
- if hasattr(long_repr, 'addsection'):
- log = item.catch_log_handler.stream.getvalue().strip()
- if log:
- long_repr.addsection('Captured log', log)
-
- # Release the handler resources.
- item.catch_log_handler.close()
- del item.catch_log_handler
-
- return report
-
-
-class CatchLogHandler(logging.StreamHandler):
+class LogCaptureHandler(logging.StreamHandler):
"""A logging handler that stores log records and the log text."""
def __init__(self):
@@ -122,24 +178,28 @@ class CatchLogHandler(logging.StreamHandler):
logging.StreamHandler.emit(self, record)
-class CatchLogFuncArg(object):
+class LogCaptureFixture(object):
"""Provides access and control of log capturing."""
- def __init__(self, handler):
- """Creates a new funcarg."""
+ @property
+ def handler(self):
+ return self._item.catch_log_handler
- self.handler = handler
+ def __init__(self, item):
+ """Creates a new funcarg."""
+ self._item = item
+ @property
def text(self):
"""Returns the log text."""
-
return self.handler.stream.getvalue()
+ @property
def records(self):
"""Returns the list of log records."""
-
return self.handler.records
+ @property
def record_tuples(self):
"""Returns a list of a striped down version of log records intended
for use in assertion comparison.
@@ -148,7 +208,7 @@ class CatchLogFuncArg(object):
(logger_name, log_level, message)
"""
- return [(r.name, r.levelno, r.getMessage()) for r in self.records()]
+ return [(r.name, r.levelno, r.getMessage()) for r in self.records]
def set_level(self, level, logger=None):
"""Sets the level for capturing of logs.
@@ -170,37 +230,84 @@ class CatchLogFuncArg(object):
"""
obj = logger and logging.getLogger(logger) or self.handler
- return CatchLogLevel(obj, level)
+ return logging_at_level(level, obj)
-class CatchLogLevel(object):
- """Context manager that sets the logging level of a handler or logger."""
+class CallablePropertyMixin(object):
+ """Backward compatibility for functions that became properties."""
- def __init__(self, obj, level):
- """Creates a new log level context manager."""
+ @classmethod
+ def compat_property(cls, func):
+ if isinstance(func, property):
+ make_property = func.getter
+ func = func.fget
+ else:
+ make_property = property
- self.obj = obj
- self.level = level
+ @functools.wraps(func)
+ def getter(self):
+ naked_value = func(self)
+ ret = cls(naked_value)
+ ret._naked_value = naked_value
+ ret._warn_compat = self._warn_compat
+ ret._prop_name = func.__name__
+ return ret
- def __enter__(self):
- """Adjust the log level."""
+ return make_property(getter)
- self.orig_level = self.obj.level
- self.obj.setLevel(self.level)
+ def __call__(self):
+ self._warn_compat(old="'caplog.{0}()' syntax".format(self._prop_name),
+ new="'caplog.{0}' property".format(self._prop_name))
+ return self._naked_value # to let legacy clients modify the object
- def __exit__(self, exc_type, exc_value, traceback):
- """Restore the log level."""
+class CallableList(CallablePropertyMixin, list):
+ pass
- self.obj.setLevel(self.orig_level)
+class CallableStr(CallablePropertyMixin, str):
+ pass
-def pytest_funcarg__caplog(request):
- """Returns a funcarg to access and control log capturing."""
+class CompatLogCaptureFixture(LogCaptureFixture):
+ """Backward compatibility with pytest-capturelog."""
- return CatchLogFuncArg(request._pyfuncitem.catch_log_handler)
+ def _warn_compat(self, old, new):
+ self._item.warn(code='L1',
+ message=("{0} is deprecated, use {1} instead"
+ .format(old, new)))
+ @CallableStr.compat_property
+ def text(self):
+ return super(CompatLogCaptureFixture, self).text
+
+ @CallableList.compat_property
+ def records(self):
+ return super(CompatLogCaptureFixture, self).records
+
+ @CallableList.compat_property
+ def record_tuples(self):
+ return super(CompatLogCaptureFixture, self).record_tuples
+
+ def setLevel(self, level, logger=None):
+ self._warn_compat(old="'caplog.setLevel()'",
+ new="'caplog.set_level()'")
+ return self.set_level(level, logger)
-def pytest_funcarg__capturelog(request):
- """Returns a funcarg to access and control log capturing."""
+ def atLevel(self, level, logger=None):
+ self._warn_compat(old="'caplog.atLevel()'",
+ new="'caplog.at_level()'")
+ return self.at_level(level, logger)
+
+
+@pytest.fixture
+def caplog(request):
+ """Access and control log capturing.
+
+ Captured logs are available through the following methods::
+
+ * caplog.text() -> string containing formatted log output
+ * caplog.records() -> list of logging.LogRecord instances
+ * caplog.record_tuples() -> list of (logger_name, level, message) tuples
+ """
+ return CompatLogCaptureFixture(request.node)
- return CatchLogFuncArg(request._pyfuncitem.catch_log_handler)
+capturelog = caplog