Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge pytest-catchlog plugin #2794

Merged
merged 33 commits into from
Oct 12, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8eafbd0
Merge the pytest-catchlog plugin
twmr Sep 13, 2017
6607478
Add unittests for LoggingPlugin (excluding perf tests)
twmr Sep 13, 2017
5130f57
Fix name clash
twmr Sep 13, 2017
ca46f4f
Remove conftest
twmr Sep 13, 2017
a8e3eff
Upgrade py
twmr Sep 13, 2017
1ba219e
Adapt (logging) unittest
twmr Sep 13, 2017
1bea7e6
Cleanup pytest_addoption
twmr Sep 13, 2017
98209e9
Remove superfluous whitespace in docstring
twmr Sep 13, 2017
3e71a50
Remove unneeded sys import from unittest
twmr Sep 13, 2017
36cceeb
Set type of log_print ini-variable to 'bool'
twmr Sep 13, 2017
a1bd54e
Clean-up LogCaptureHandler
twmr Sep 13, 2017
fc965c1
Remove outdated docstring
twmr Sep 13, 2017
87b8dc5
Move 'config' handling from pytest_configure to __init__
twmr Sep 15, 2017
f1f6109
Remove _catchlog_ prefix
twmr Sep 15, 2017
d13e17c
Don't modify the 'config' object in __init__
twmr Sep 15, 2017
08f6b5f
Use pytest.hookimpl instead of pytest.mark.hookwrapper
twmr Sep 15, 2017
e41fd52
Introduce live_logs context manager
twmr Sep 15, 2017
57f66a4
catching_logs: Remove unused 'filter' kwarg
twmr Sep 15, 2017
3a40115
catching_logs: Remove usage of 'closing' ctx manager
twmr Sep 15, 2017
207f153
Remove logging_at_level ctx manager
twmr Sep 17, 2017
2559ec8
use 'formatter' kwarg of catching_logs
twmr Sep 17, 2017
ad21d5c
Remove pytest-capturelog backward compat code
twmr Sep 17, 2017
296ac5c
Add thisch to AUTHORS
twmr Sep 22, 2017
69f3bd8
Add changelog entry for catchlog plugin
twmr Sep 22, 2017
0ec72d0
Improve get_option_ini and get_actual_log_level
twmr Sep 26, 2017
1f3ab11
Remove usage of get_logger_obj
twmr Sep 26, 2017
815dd19
Remove unicode literal compat code
twmr Sep 26, 2017
0e83511
Rename name of registered logging plugin
twmr Sep 26, 2017
502652f
Add preliminary documentation for logging-plugin
twmr Sep 26, 2017
775f4a6
Fix flake8 issue
twmr Sep 27, 2017
f3261d9
Move logging docu into own rst file
twmr Sep 26, 2017
8aed5fe
Remove test_logging_initialized_in_test
twmr Oct 11, 2017
af75ca4
Fix some coding-style issues in the logging plugin
twmr Oct 11, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Tarcisio Fischer
Tareq Alayan
Ted Xiao
Thomas Grainger
Thomas Hisch
Tom Viner
Trevor Bekolay
Tyler Goodlet
Expand Down
2 changes: 1 addition & 1 deletion _pytest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def directory_arg(path, optname):
"mark main terminal runner python fixtures debugging unittest capture skipping "
"tmpdir monkeypatch recwarn pastebin helpconfig nose assertion "
"junitxml resultlog doctest cacheprovider freeze_support "
"setuponly setupplan warnings").split()
"setuponly setupplan warnings logging").split()


builtin_plugins = set(default_plugins)
Expand Down
337 changes: 337 additions & 0 deletions _pytest/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
from __future__ import absolute_import, division, print_function

import logging
from contextlib import closing, contextmanager
import sys
import six

import pytest
import py


DEFAULT_LOG_FORMAT = '%(filename)-25s %(lineno)4d %(levelname)-8s %(message)s'
DEFAULT_LOG_DATE_FORMAT = '%H:%M:%S'


def get_option_ini(config, *names):
for name in names:
ret = config.getoption(name) # 'default' arg won't work as expected
if ret is None:
ret = config.getini(name)
if ret:
return ret


def pytest_addoption(parser):
"""Add options to control log capturing."""
group = parser.getgroup('logging')

def add_option_ini(option, dest, default=None, type=None, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while reiterating this as it got back into my notifications i beleive this one should have never met a release,
literally all the options we add are technically superseeded by the much better option setting argument

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean, all command-line options could be replaced by using the -o option instead.

While I agree with your reasoning, we have to remember that one of the objectives was to merge this plugin as is to allow a drop-in-replacement for users, so removing these options would break their usage.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should nonetheless mark them for removal in future

parser.addini(dest, default=default, type=type,
help='default value for ' + option)
group.addoption(option, dest=dest, **kwargs)

add_option_ini(
'--no-print-logs',
dest='log_print', action='store_const', const=False, default=True,
type='bool',
help='disable printing caught logs on failed tests.')
add_option_ini(
'--log-level',
dest='log_level', default=None,
help='logging level used by the logging module')
add_option_ini(
'--log-format',
dest='log_format', default=DEFAULT_LOG_FORMAT,
help='log format as used by the logging module.')
add_option_ini(
'--log-date-format',
dest='log_date_format', default=DEFAULT_LOG_DATE_FORMAT,
help='log date format as used by the logging module.')
add_option_ini(
'--log-cli-level',
dest='log_cli_level', default=None,
help='cli logging level.')
add_option_ini(
'--log-cli-format',
dest='log_cli_format', default=None,
help='log format as used by the logging module.')
add_option_ini(
'--log-cli-date-format',
dest='log_cli_date_format', default=None,
help='log date format as used by the logging module.')
add_option_ini(
'--log-file',
dest='log_file', default=None,
help='path to a file when logging will be written to.')
add_option_ini(
'--log-file-level',
dest='log_file_level', default=None,
help='log file logging level.')
add_option_ini(
'--log-file-format',
dest='log_file_format', default=DEFAULT_LOG_FORMAT,
help='log format as used by the logging module.')
add_option_ini(
'--log-file-date-format',
dest='log_file_date_format', default=DEFAULT_LOG_DATE_FORMAT,
help='log date format as used by the logging module.')


@contextmanager
def logging_using_handler(handler, logger=None):
"""Context manager that safely registers a given handler."""
logger = logger or logging.getLogger(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, formatter=None,
level=logging.NOTSET, logger=None):
"""Context manager that prepares the whole logging machinery properly."""
logger = logger or logging.getLogger(logger)

if formatter is not None:
handler.setFormatter(formatter)
handler.setLevel(level)

with logging_using_handler(handler, logger):
orig_level = logger.level
logger.setLevel(min(orig_level, level))
try:
yield handler
finally:
logger.setLevel(orig_level)


class LogCaptureHandler(logging.StreamHandler):
"""A logging handler that stores log records and the log text."""

def __init__(self):
"""Creates a new log handler."""
logging.StreamHandler.__init__(self, py.io.TextIO())
self.records = []

def emit(self, record):
"""Keep the log records in a list in addition to the log text."""
self.records.append(record)
logging.StreamHandler.emit(self, record)


class LogCaptureFixture(object):
"""Provides access and control of log capturing."""

def __init__(self, item):
"""Creates a new funcarg."""
self._item = item

@property
def handler(self):
return self._item.catch_log_handler

@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.

The format of the tuple is:

(logger_name, log_level, message)
"""
return [(r.name, r.levelno, r.getMessage()) for r in self.records]

def clear(self):
"""Reset the list of log records."""
self.handler.records = []

def set_level(self, level, logger=None):
"""Sets the level for capturing of logs.

By default, the level is set on the handler used to capture
logs. Specify a logger name to instead set the level of any
logger.
"""
if logger is None:
logger = self.handler
else:
logger = logging.getLogger(logger)
logger.setLevel(level)

@contextmanager
def at_level(self, level, logger=None):
"""Context manager that sets the level for capturing of logs.

By default, the level is set on the handler used to capture
logs. Specify a logger name to instead set the level of any
logger.
"""
if logger is None:
logger = self.handler
else:
logger = logging.getLogger(logger)
Copy link
Contributor Author

@twmr twmr Sep 26, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove the else: .. code and enforce that the logger kwarg must be either None or an instance of a logger object.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that would break the current interface, the logger parameter currently is a str/name. I would like to avoid breaking the current API so users that were using pytest-catchlog can use the builtin logging without changes if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, then let's leave it as is.


orig_level = logger.level
logger.setLevel(level)
try:
yield
finally:
logger.setLevel(orig_level)


@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 LogCaptureFixture(request.node)


def get_actual_log_level(config, *setting_names):
"""Return the actual logging level."""

for setting_name in setting_names:
log_level = config.getoption(setting_name)
if log_level is None:
log_level = config.getini(setting_name)
if log_level:
break
else:
return

if isinstance(log_level, six.string_types):
log_level = log_level.upper()
try:
return int(getattr(logging, log_level, log_level))
except ValueError:
# Python logging does not recognise this as a logging level
raise pytest.UsageError(
"'{0}' is not recognized as a logging level name for "
"'{1}'. Please consider passing the "
"logging level num instead.".format(
log_level,
setting_name))


def pytest_configure(config):
config.pluginmanager.register(LoggingPlugin(config),
'logging-plugin')


class LoggingPlugin(object):
"""Attaches to the logging module and captures log messages for each test.
"""

def __init__(self, config):
"""Creates a new plugin to capture log messages.

The formatter can be safely shared across all handlers so
create a single one for the entire test session here.
"""
self.log_cli_level = get_actual_log_level(
config, 'log_cli_level', 'log_level') or logging.WARNING

self.print_logs = get_option_ini(config, 'log_print')
self.formatter = logging.Formatter(
get_option_ini(config, 'log_format'),
get_option_ini(config, 'log_date_format'))

log_cli_handler = logging.StreamHandler(sys.stderr)
log_cli_format = get_option_ini(
config, 'log_cli_format', 'log_format')
log_cli_date_format = get_option_ini(
config, 'log_cli_date_format', 'log_date_format')
log_cli_formatter = logging.Formatter(
log_cli_format,
datefmt=log_cli_date_format)
self.log_cli_handler = log_cli_handler # needed for a single unittest
self.live_logs = catching_logs(log_cli_handler,
formatter=log_cli_formatter,
level=self.log_cli_level)

log_file = get_option_ini(config, 'log_file')
if log_file:
self.log_file_level = get_actual_log_level(
config, 'log_file_level') or logging.WARNING

log_file_format = get_option_ini(
config, 'log_file_format', 'log_format')
log_file_date_format = get_option_ini(
config, 'log_file_date_format', 'log_date_format')
self.log_file_handler = logging.FileHandler(
log_file,
# Each pytest runtests session will write to a clean logfile
mode='w')
log_file_formatter = logging.Formatter(
log_file_format,
datefmt=log_file_date_format)
self.log_file_handler.setFormatter(log_file_formatter)
else:
self.log_file_handler = None

@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.hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item):
with self._runtest_for(item, 'setup'):
yield

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item):
with self._runtest_for(item, 'call'):
yield

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_teardown(self, item):
with self._runtest_for(item, 'teardown'):
yield

@pytest.hookimpl(hookwrapper=True)
def pytest_runtestloop(self, session):
"""Runs all collected test items."""
with self.live_logs:
if self.log_file_handler is not None:
with closing(self.log_file_handler):
with catching_logs(self.log_file_handler,
level=self.log_file_level):
yield # run all the tests
else:
yield # run all the tests
3 changes: 3 additions & 0 deletions changelog/2794.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Pytest now captures and displays output from the standard `logging` module. The user can control the logging level to be captured by specifying options in ``pytest.ini``, the command line and also during individual tests using markers. Also, a ``caplog`` fixture is available that enables users to test the captured log during specific tests (similar to ``capsys`` for example). For more information, please see the `docs <https://docs.pytest.org/en/latest/logging.html>`_.

This feature was introduced by merging the popular `pytest-catchlog <https://pypi.org/project/pytest-catchlog/>`_ plugin, thanks to `Thomas Hisch <https://github.com/thisch>`_. Be advised that during the merging the backward compatibility interface with the defunct ``pytest-capturelog`` has been dropped.
1 change: 1 addition & 0 deletions doc/en/contents.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Full pytest documentation
xunit_setup
plugins
writing_plugins
logging

goodpractices
pythonpath
Expand Down
Loading