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 20 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
199 changes: 139 additions & 60 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,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):
Expand Down Expand Up @@ -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)
Copy link
Contributor

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, will do!

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 +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()
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 +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):
Expand All @@ -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
Expand Down Expand Up @@ -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
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 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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 logger to also write to stdout:

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

Copy link
Contributor

Choose a reason for hiding this comment

The 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?
Maybe it would be nice test addition to the existing ones as well.
Again, thanks for the help.

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

The 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 conftest.py file, I get the live logging output:

test_template.py::test_function_1
--------------------------------------------------- live log setup ----------------------------------------------------
test_template.py            30 INFO     Hi with info from test 1 setup
---------------------------------------------------- live log call ----------------------------------------------------
test_template.py            35 INFO     Hi with info from test 1
test_template.py            36 ERROR    Hi with error from test 1
PASSED                                                                                                           [100%]

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 propagate: 0 configuration which needs to be removed as commented before).

IMHO this is an acceptable workaround. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

You 're super! I can definitely WA my use case! Thanks :)

Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

The 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()
Copy link
Contributor

Choose a reason for hiding this comment

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

I hope that this is a cheap operation, is this the case?

Copy link
Member Author

Choose a reason for hiding this comment

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

I wouldn't expect it to be too expensive; for sys capturing is a matter of reading from a file the captured contents so far and changing sys.stdout back to its original value; for fd capturing it is the same as sys with the addition of a os.dup2 call.

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