-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Changes from all commits
8eafbd0
6607478
5130f57
ca46f4f
a8e3eff
1ba219e
1bea7e6
98209e9
3e71a50
36cceeb
a1bd54e
fc965c1
87b8dc5
f1f6109
d13e17c
08f6b5f
e41fd52
57f66a4
3a40115
207f153
2559ec8
ad21d5c
296ac5c
69f3bd8
0ec72d0
1f3ab11
815dd19
0e83511
502652f
775f4a6
f3261d9
8aed5fe
af75ca4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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): | ||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should remove the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But that would break the current interface, the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,6 +30,7 @@ Full pytest documentation | |
xunit_setup | ||
plugins | ||
writing_plugins | ||
logging | ||
|
||
goodpractices | ||
pythonpath | ||
|
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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