-
-
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
Changes in the logging plugin for 3.4 #3124
Changes from 20 commits
5ad1313
8dcd271
aca1b06
8d735f3
6bb7395
c53b72f
5d89a93
8f6a592
97a4967
4a43657
9dbcac9
18e0535
2e40a8b
27ae270
29a7b5e
0df42b4
3a9d0b2
9f4688e
113bfb6
b4e8861
2f955e0
15cbd61
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 |
---|---|---|
|
@@ -2,7 +2,6 @@ | |
|
||
import logging | ||
from contextlib import closing, contextmanager | ||
import sys | ||
import six | ||
|
||
import pytest | ||
|
@@ -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, | ||
|
@@ -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) | ||
|
||
|
@@ -123,6 +128,17 @@ 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): | ||
|
@@ -161,31 +177,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. | ||
|
||
: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. | ||
|
||
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. | ||
.. 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: | ||
|
@@ -204,7 +220,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() | ||
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. I guess that a try finally block is needed here around 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. Your suggestion is true for 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. Huh did not know that. Slick. |
||
|
||
|
||
def get_actual_log_level(config, *setting_names): | ||
|
@@ -234,8 +252,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): | ||
|
@@ -248,52 +270,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 | ||
|
@@ -325,14 +337,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 | ||
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. I guess this is the reason that when I enabled log_cli I can't get any logs into the junitxml file. Junitxml plugin AFAIK gets all captured logs sections and puts them inside the xml file. If you don't capture logs here, when the log_cli is enabled, we will never be able to see logs in console and in the xml file. 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. Good point @KKoukiou. Looking at your configuration: version: 1
handlers:
consoleHandler:
class: logging.StreamHandler
level: INFO
stream: ext://sys.stdout
simpleFileHandler:
class: logging.FileHandler
filename: logs.txt
loggers:
art.flow:
level: INFO
handlers:
- consoleHandler
propagate: 0
root:
level: DEBUG
handlers:
- simpleFileHandler
disable_existing_loggers: False I believe if you configure the root:
level: DEBUG
handlers:
- simpleFileHandler
- consoleHandler You will then see the output written to junitxml (that's one of the things I planned on trying later today). 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. It doesn't work but I didn't expect since all logs are captured from art.flow logger which is not propagating the logs to root logger, thus don't see why this change would affect it. Maybe is would be better for you to provide me with a working minimal example where logs appear in console and the same logs appear in the xmlfile? 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. I will get to this later when I get home and let you know. 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. @KKoukiou out of the box your requirements (showing captured log in the terminal while tests are running and capture it so it appears in the junit xml) are not possible, I'm afraid. But I managed to make things work like you expect by writing a fixture which manually adds the captured log information to the item's sections: @pytest.fixture(autouse=True)
def append_captured_log_to_item_stdout(request, caplog):
yield
for when in ('setup', 'call', 'teardown'):
records = caplog.get_handler(when).records
for record in records:
request.node.add_report_section(when, 'stdout', record.message + '\n') With this fixture in the root
And the log messages also appear in the xml file: <?xml version="1.0" encoding="utf-8"?><testsuite errors="0" failures="0" name="pytest" skips="0" tests="1" time="0.079"><testcase classname="test_template" file="test_template.py" line="31" name="test_function_1" time="0.011009693145751953"><system-out>Hi with info from test 1 setup
Hi with info from test 1
Hi with error from test 1
</system-out></testcase></testsuite> (This is obtained with your original logging configuration, except for the IMHO this is an acceptable workaround. What do you think? 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. You 're super! I can definitely WA my use case! Thanks :) 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. @KKoukiou can you try the following patch and test it (without the conftest.py fixture)? diff --git a/_pytest/junitxml.py b/_pytest/junitxml.py
index 7fb40dc..0cbec7a 100644
--- a/_pytest/junitxml.py
+++ b/_pytest/junitxml.py
@@ -125,11 +125,14 @@ class _NodeReporter(object):
self.append(node)
def write_captured_output(self, report):
- for capname in ('out', 'err'):
- content = getattr(report, 'capstd' + capname)
- if content:
- tag = getattr(Junit, 'system-' + capname)
- self.append(tag(bin_xml_escape(content)))
+ content = report.caplog # TODO merge with report.capstdout or add a junit flag to switch between stdout and caplog
+ if content:
+ tag = getattr(Junit, 'system-out')
+ self.append(tag(bin_xml_escape(content)))
+ content = report.capstderr
+ if content:
+ tag = getattr(Junit, 'system-err')
+ self.append(tag(bin_xml_escape(content)))
def append_pass(self, report):
self.add_stats('passed')
diff --git a/_pytest/runner.py b/_pytest/runner.py
index 13abee3..b475be9 100644
--- a/_pytest/runner.py
+++ b/_pytest/runner.py
@@ -257,6 +257,14 @@ class BaseReport(object):
return exc.strip()
@property
+ def caplog(self):
+ """Return captured log lines, if log capturing is enabled
+
+ .. versionadded:: 3.4
+ """
+ return '\n'.join(content for (prefix, content) in self.get_sections('Captured log'))
+
+ @property
def capstdout(self):
"""Return captured text from stdout, if capturing is enabled @nicoddemus Do you have interest in integrating a fix for junitxml into pytest? 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. @Thisch @nicoddemus I confirm that with this patch I get the expected output in console and junitxml file as with the conftest.py adjustment. 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. @Thisch I'm not sure, TBH I would like to avoid to add more to this PR, it is quite big as it is already. Also I think merging capout and caplog needs more detailed discussion. 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. Thx for the confirmation @KKoukiou. I totally agree that this PR is already quite large @nicoddemus. Let's create a new ticket for this issue. |
||
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() | ||
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. I hope that this is a cheap operation, is this the case? 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. I wouldn't expect it to be too expensive; for |
||
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() |
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. |
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.
Nice usage of setdefault! However, a comment describing what this line does would be nice.
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.
Sure, will do!