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

Changes in the logging plugin for 3.4 #3124

Merged
merged 22 commits into from
Jan 26, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5ad1313
log_cli must now be enabled explicitly
nicoddemus Jan 15, 2018
8dcd271
No longer change the level of any logger unless requested explicitly
nicoddemus Jan 16, 2018
aca1b06
Undo log level set by caplog.set_level at the end of the test
nicoddemus Jan 16, 2018
8d735f3
Live log option now writes to the terminal reporter
nicoddemus Jan 16, 2018
6bb7395
Update logging docs with the new changes in 3.4
nicoddemus Jan 16, 2018
c53b72f
Add CHANGELOG for 3013
nicoddemus Jan 16, 2018
5d89a93
Small improvements to tests suggested during review
nicoddemus Jan 17, 2018
8f6a592
Add newline before log messages and enable -v output when log_cli is …
nicoddemus Jan 17, 2018
97a4967
Improve code formatting
nicoddemus Jan 17, 2018
4a43657
Simplify test assertions a bit
nicoddemus Jan 17, 2018
9dbcac9
Suspend stdout/stderr capturing when emitting live logging messages
nicoddemus Jan 18, 2018
18e0535
Use six.StringIO and __name__ in test_live_logging_suspends_capture
nicoddemus Jan 20, 2018
2e40a8b
Fix test_caplog_captures_for_all_stages by setting log level
nicoddemus Jan 20, 2018
27ae270
Mention in docs that log messages of level WARNING or above are captured
nicoddemus Jan 20, 2018
29a7b5e
Initialize log_cli_handler to None during LoggingPlugin init
nicoddemus Jan 20, 2018
0df42b4
Show a header for each testing phase during live logging
nicoddemus Jan 22, 2018
3a9d0b2
Use pytest_runtest_logstart to signal the start of a new test
nicoddemus Jan 22, 2018
9f4688e
Remove unnecessary -s from test_log_cli_enabled_disabled
nicoddemus Jan 22, 2018
113bfb6
Report 'call' phase as 'live log call'
nicoddemus Jan 22, 2018
b4e8861
Fix typos
nicoddemus Jan 23, 2018
2f955e0
Update documentation: rewording and move things for better reading flow
nicoddemus Jan 24, 2018
15cbd61
Change caplog.get_handler(when) to caplog.get_records(when)
nicoddemus Jan 24, 2018
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
218 changes: 154 additions & 64 deletions _pytest/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

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

import pytest
Expand Down Expand Up @@ -48,6 +47,9 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs):
'--log-date-format',
dest='log_date_format', default=DEFAULT_LOG_DATE_FORMAT,
help='log date format as used by the logging module.')
parser.addini(
'log_cli', default=False, type='bool',
help='enable log display during test run (also known as "live logging").')
add_option_ini(
'--log-cli-level',
dest='log_cli_level', default=None,
Expand Down Expand Up @@ -79,26 +81,29 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs):


@contextmanager
def catching_logs(handler, formatter=None, level=logging.NOTSET):
def catching_logs(handler, formatter=None, level=None):
"""Context manager that prepares the whole logging machinery properly."""
root_logger = logging.getLogger()

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

# Adding the same handler twice would confuse logging system.
# Just don't do that.
add_new_handler = handler not in root_logger.handlers

if add_new_handler:
root_logger.addHandler(handler)
orig_level = root_logger.level
root_logger.setLevel(min(orig_level, level))
if level is not None:
orig_level = root_logger.level
root_logger.setLevel(level)
try:
yield handler
finally:
root_logger.setLevel(orig_level)
if level is not None:
root_logger.setLevel(orig_level)
if add_new_handler:
root_logger.removeHandler(handler)

Expand All @@ -123,17 +128,39 @@ class LogCaptureFixture(object):
def __init__(self, item):
"""Creates a new funcarg."""
self._item = item
self._initial_log_levels = {} # type: Dict[str, int] # dict of log name -> log level

def _finalize(self):
"""Finalizes the fixture.

This restores the log levels changed by :meth:`set_level`.
"""
# restore log levels
for logger_name, level in self._initial_log_levels.items():
logger = logging.getLogger(logger_name)
logger.setLevel(level)

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

def get_handler(self, when):
def get_records(self, when):
"""
Get the handler for a specified state of the tests.
Valid values for the when parameter are: 'setup', 'call' and 'teardown'.
Get the logging records for one of the possible test phases.

:param str when:
Which test phase to obtain the records from. Valid values are: "setup", "call" and "teardown".

:rtype: List[logging.LogRecord]
:return: the list of captured records at the given stage

.. versionadded:: 3.4
"""
return self._item.catch_log_handlers.get(when)
handler = self._item.catch_log_handlers.get(when)
if handler:
return handler.records
else:
return []

@property
def text(self):
Expand Down Expand Up @@ -161,31 +188,31 @@ def clear(self):
self.handler.records = []

def set_level(self, level, logger=None):
"""Sets the level for capturing of logs.
"""Sets the level for capturing of logs. The level will be restored to its previous value at the end of
the test.

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.
:param int level: the logger to level.
:param str logger: the logger to update the level. If not given, the root logger level is updated.

.. versionchanged:: 3.4
The levels of the loggers changed by this function will be restored to their initial values at the
end of the test.
"""
if logger is None:
logger = self.handler
else:
logger = logging.getLogger(logger)
logger_name = logger
logger = logging.getLogger(logger_name)
# save the original log-level to restore it during teardown
self._initial_log_levels.setdefault(logger_name, logger.level)
logger.setLevel(level)

@contextmanager
def at_level(self, level, logger=None):
"""Context manager that sets the level for capturing of logs.
"""Context manager that sets the level for capturing of logs. After the end of the 'with' statement the
level is restored to its original value.

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.
:param int level: the logger to level.
:param str logger: the logger to update the level. If not given, the root logger level is updated.
"""
if logger is None:
logger = self.handler
else:
logger = logging.getLogger(logger)

logger = logging.getLogger(logger)
orig_level = logger.level
logger.setLevel(level)
try:
Expand All @@ -204,7 +231,9 @@ def caplog(request):
* caplog.records() -> list of logging.LogRecord instances
* caplog.record_tuples() -> list of (logger_name, level, message) tuples
"""
return LogCaptureFixture(request.node)
result = LogCaptureFixture(request.node)
yield result
result._finalize()
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess that a try finally block is needed here around yield result. Otherwise result._finalize() is not called if an exception is raised in the code called after yield result.

Copy link
Member Author

Choose a reason for hiding this comment

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

Your suggestion is true for @contextmanager, but it is not needed for pytest fixtures: the code after the yield is always executed regardless if an exception has been raised in the test or not. 😉

Copy link

Choose a reason for hiding this comment

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

Huh did not know that. Slick.



def get_actual_log_level(config, *setting_names):
Expand Down Expand Up @@ -234,8 +263,12 @@ def get_actual_log_level(config, *setting_names):


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


@contextmanager
def _dummy_context_manager():
yield


class LoggingPlugin(object):
Expand All @@ -248,52 +281,42 @@ def __init__(self, config):
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._config = config

# enable verbose output automatically if live logging is enabled
if self._config.getini('log_cli') and not config.getoption('verbose'):
# sanity check: terminal reporter should not have been loaded at this point
assert self._config.pluginmanager.get_plugin('terminalreporter') is None
config.option.verbose = 1

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)
self.formatter = logging.Formatter(get_option_ini(config, 'log_format'),
get_option_ini(config, 'log_date_format'))
self.log_level = get_actual_log_level(config, 'log_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_level = get_actual_log_level(config, 'log_file_level')

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')
# Each pytest runtests session will write to a clean logfile
self.log_file_handler = logging.FileHandler(log_file, 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

# initialized during pytest_runtestloop
self.log_cli_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:
formatter=self.formatter, level=self.log_level) as log_handler:
if self.log_cli_handler:
self.log_cli_handler.set_when(when)
if not hasattr(item, 'catch_log_handlers'):
item.catch_log_handlers = {}
item.catch_log_handlers[when] = log_handler
Expand Down Expand Up @@ -325,14 +348,81 @@ def pytest_runtest_teardown(self, item):
with self._runtest_for(item, 'teardown'):
yield

def pytest_runtest_logstart(self):
if self.log_cli_handler:
self.log_cli_handler.reset()

@pytest.hookimpl(hookwrapper=True)
def pytest_runtestloop(self, session):
"""Runs all collected test items."""
with self.live_logs:
self._setup_cli_logging()
with self.live_logs_context:
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

def _setup_cli_logging(self):
"""Sets up the handler and logger for the Live Logs feature, if enabled.

This must be done right before starting the loop so we can access the terminal reporter plugin.
"""
terminal_reporter = self._config.pluginmanager.get_plugin('terminalreporter')
if self._config.getini('log_cli') and terminal_reporter is not None:
capture_manager = self._config.pluginmanager.get_plugin('capturemanager')
log_cli_handler = _LiveLoggingStreamHandler(terminal_reporter, capture_manager)
log_cli_format = get_option_ini(self._config, 'log_cli_format', 'log_format')
log_cli_date_format = get_option_ini(self._config, 'log_cli_date_format', 'log_date_format')
log_cli_formatter = logging.Formatter(log_cli_format, datefmt=log_cli_date_format)
log_cli_level = get_actual_log_level(self._config, 'log_cli_level', 'log_level')
self.log_cli_handler = log_cli_handler
self.live_logs_context = catching_logs(log_cli_handler, formatter=log_cli_formatter, level=log_cli_level)
else:
self.live_logs_context = _dummy_context_manager()


class _LiveLoggingStreamHandler(logging.StreamHandler):
"""
Custom StreamHandler used by the live logging feature: it will write a newline before the first log message
in each test.

During live logging we must also explicitly disable stdout/stderr capturing otherwise it will get captured
and won't appear in the terminal.
"""

def __init__(self, terminal_reporter, capture_manager):
"""
:param _pytest.terminal.TerminalReporter terminal_reporter:
:param _pytest.capture.CaptureManager capture_manager:
"""
logging.StreamHandler.__init__(self, stream=terminal_reporter)
self.capture_manager = capture_manager
self.reset()
self.set_when(None)

def reset(self):
"""Reset the handler; should be called before the start of each test"""
self._first_record_emitted = False

def set_when(self, when):
"""Prepares for the given test phase (setup/call/teardown)"""
self._when = when
self._section_name_shown = False

def emit(self, record):
if self.capture_manager is not None:
self.capture_manager.suspend_global_capture()
try:
if not self._first_record_emitted or self._when == 'teardown':
self.stream.write('\n')
self._first_record_emitted = True
if not self._section_name_shown:
self.stream.section('live log ' + self._when, sep='-', bold=True)
self._section_name_shown = True
logging.StreamHandler.emit(self, record)
finally:
if self.capture_manager is not None:
self.capture_manager.resume_global_capture()
1 change: 1 addition & 0 deletions changelog/3013.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Incompatible change**: after community feedback the `logging <https://docs.pytest.org/en/latest/logging.html>`_ functionality has undergone some changes. Please consult the `logging documentation <https://docs.pytest.org/en/latest/logging.html#incompatible-changes-in-pytest-3-4>`_ for details.
2 changes: 1 addition & 1 deletion changelog/3117.feature
Original file line number Diff line number Diff line change
@@ -1 +1 @@
New ``caplog.get_handler(when)`` method which provides access to the underlying ``Handler`` class used to capture logging during each testing stage, allowing users to obtain the captured records during ``"setup"`` and ``"teardown"`` stages.
New ``caplog.get_records(when)`` method which provides access the captured records during each testing stage: ``"setup"``, ``"call"`` and ``"teardown"`` stages.
Loading