Merge pull request #3124 from nicoddemus/logging-3013
Changes in the logging plugin for 3.4
This commit is contained in:
commit
a58099022a
|
@ -2,7 +2,6 @@ from __future__ import absolute_import, division, print_function
|
|||
|
||||
import logging
|
||||
from contextlib import closing, contextmanager
|
||||
import sys
|
||||
import six
|
||||
|
||||
import pytest
|
||||
|
@ -48,6 +47,9 @@ def pytest_addoption(parser):
|
|||
'--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,13 +81,14 @@ def pytest_addoption(parser):
|
|||
|
||||
|
||||
@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.
|
||||
|
@ -93,12 +96,14 @@ def catching_logs(handler, formatter=None, level=logging.NOTSET):
|
|||
|
||||
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,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):
|
||||
|
@ -161,31 +188,31 @@ class LogCaptureFixture(object):
|
|||
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:
|
||||
|
@ -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()
|
||||
|
||||
|
||||
def get_actual_log_level(config, *setting_names):
|
||||
|
@ -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):
|
||||
|
@ -248,52 +281,42 @@ class LoggingPlugin(object):
|
|||
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
|
||||
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')
|
||||
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)
|
||||
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,10 +348,15 @@ class LoggingPlugin(object):
|
|||
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,
|
||||
|
@ -336,3 +364,65 @@ class LoggingPlugin(object):
|
|||
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()
|
||||
|
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
|
@ -3,24 +3,11 @@
|
|||
Logging
|
||||
-------
|
||||
|
||||
.. versionadded 3.3.0
|
||||
.. versionadded:: 3.3
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
.. note::
|
||||
|
||||
This feature is a drop-in replacement for the `pytest-catchlog
|
||||
<https://pypi.org/project/pytest-catchlog/>`_ plugin and they will conflict
|
||||
with each other. The backward compatibility API with ``pytest-capturelog``
|
||||
has been dropped when this feature was introduced, so if for that reason you
|
||||
still need ``pytest-catchlog`` you can disable the internal feature by
|
||||
adding to your ``pytest.ini``:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[pytest]
|
||||
addopts=-p no:logging
|
||||
|
||||
Log messages are captured by default and for each failed test will be shown in
|
||||
the same manner as captured stdout and stderr.
|
||||
pytest captures log messages of level ``WARNING`` or above automatically and displays them in their own section
|
||||
for each failed test in the same manner as captured stdout and stderr.
|
||||
|
||||
Running without options::
|
||||
|
||||
|
@ -29,7 +16,7 @@ Running without options::
|
|||
Shows failed tests like so::
|
||||
|
||||
----------------------- Captured stdlog call ----------------------
|
||||
test_reporting.py 26 INFO text going to logger
|
||||
test_reporting.py 26 WARNING text going to logger
|
||||
----------------------- Captured stdout call ----------------------
|
||||
text going to stdout
|
||||
----------------------- Captured stderr call ----------------------
|
||||
|
@ -37,11 +24,10 @@ Shows failed tests like so::
|
|||
==================== 2 failed in 0.02 seconds =====================
|
||||
|
||||
By default each captured log message shows the module, line number, log level
|
||||
and message. Showing the exact module and line number is useful for testing and
|
||||
debugging. If desired the log format and date format can be specified to
|
||||
anything that the logging module supports.
|
||||
and message.
|
||||
|
||||
Running pytest specifying formatting options::
|
||||
If desired the log and date format can be specified to
|
||||
anything that the logging module supports by passing specific formatting options::
|
||||
|
||||
pytest --log-format="%(asctime)s %(levelname)s %(message)s" \
|
||||
--log-date-format="%Y-%m-%d %H:%M:%S"
|
||||
|
@ -49,14 +35,14 @@ Running pytest specifying formatting options::
|
|||
Shows failed tests like so::
|
||||
|
||||
----------------------- Captured stdlog call ----------------------
|
||||
2010-04-10 14:48:44 INFO text going to logger
|
||||
2010-04-10 14:48:44 WARNING text going to logger
|
||||
----------------------- Captured stdout call ----------------------
|
||||
text going to stdout
|
||||
----------------------- Captured stderr call ----------------------
|
||||
text going to stderr
|
||||
==================== 2 failed in 0.02 seconds =====================
|
||||
|
||||
These options can also be customized through a configuration file:
|
||||
These options can also be customized through ``pytest.ini`` file:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
|
@ -69,7 +55,7 @@ with::
|
|||
|
||||
pytest --no-print-logs
|
||||
|
||||
Or in you ``pytest.ini``:
|
||||
Or in the ``pytest.ini`` file:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
|
@ -85,6 +71,10 @@ Shows failed tests in the normal manner as no logs were captured::
|
|||
text going to stderr
|
||||
==================== 2 failed in 0.02 seconds =====================
|
||||
|
||||
|
||||
caplog fixture
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
Inside tests it is possible to change the log level for the captured log
|
||||
messages. This is supported by the ``caplog`` fixture::
|
||||
|
||||
|
@ -92,7 +82,7 @@ messages. This is supported by the ``caplog`` fixture::
|
|||
caplog.set_level(logging.INFO)
|
||||
pass
|
||||
|
||||
By default the level is set on the handler used to catch the log messages,
|
||||
By default the level is set on the root logger,
|
||||
however as a convenience it is also possible to set the log level of any
|
||||
logger::
|
||||
|
||||
|
@ -100,14 +90,16 @@ logger::
|
|||
caplog.set_level(logging.CRITICAL, logger='root.baz')
|
||||
pass
|
||||
|
||||
The log levels set are restored automatically at the end of the test.
|
||||
|
||||
It is also possible to use a context manager to temporarily change the log
|
||||
level::
|
||||
level inside a ``with`` block::
|
||||
|
||||
def test_bar(caplog):
|
||||
with caplog.at_level(logging.INFO):
|
||||
pass
|
||||
|
||||
Again, by default the level of the handler is affected but the level of any
|
||||
Again, by default the level of the root logger is affected but the level of any
|
||||
logger can be changed instead with::
|
||||
|
||||
def test_bar(caplog):
|
||||
|
@ -115,7 +107,7 @@ logger can be changed instead with::
|
|||
pass
|
||||
|
||||
Lastly all the logs sent to the logger during the test run are made available on
|
||||
the fixture in the form of both the LogRecord instances and the final log text.
|
||||
the fixture in the form of both the ``logging.LogRecord`` instances and the final log text.
|
||||
This is useful for when you want to assert on the contents of a message::
|
||||
|
||||
def test_baz(caplog):
|
||||
|
@ -146,12 +138,41 @@ You can call ``caplog.clear()`` to reset the captured log records in a test::
|
|||
your_test_method()
|
||||
assert ['Foo'] == [rec.message for rec in caplog.records]
|
||||
|
||||
|
||||
The ``caplop.records`` attribute contains records from the current stage only, so
|
||||
inside the ``setup`` phase it contains only setup logs, same with the ``call`` and
|
||||
``teardown`` phases.
|
||||
|
||||
To access logs from other stages, use the ``caplog.get_records(when)`` method. As an example,
|
||||
if you want to make sure that tests which use a certain fixture never log any warnings, you can inspect
|
||||
the records for the ``setup`` and ``call`` stages during teardown like so:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(caplog):
|
||||
window = create_window()
|
||||
yield window
|
||||
for when in ('setup', 'call'):
|
||||
messages = [x.message for x in caplog.get_records(when) if x.level == logging.WARNING]
|
||||
if messages:
|
||||
pytest.fail('warning messages encountered during testing: {}'.format(messages))
|
||||
|
||||
|
||||
caplog fixture API
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. autoclass:: _pytest.logging.LogCaptureFixture
|
||||
:members:
|
||||
|
||||
.. _live_logs:
|
||||
|
||||
Live Logs
|
||||
^^^^^^^^^
|
||||
|
||||
By default, pytest will output any logging records with a level higher or
|
||||
equal to WARNING. In order to actually see these logs in the console you have to
|
||||
disable pytest output capture by passing ``-s``.
|
||||
By setting the :confval:`log_cli` configuration option to ``true``, pytest will output
|
||||
logging records as they are emitted directly into the console.
|
||||
|
||||
You can specify the logging level for which log records with equal or higher
|
||||
level are printed to the console by passing ``--log-cli-level``. This setting
|
||||
|
@ -191,11 +212,48 @@ option names are:
|
|||
* ``log_file_format``
|
||||
* ``log_file_date_format``
|
||||
|
||||
Accessing logs from other test stages
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
.. _log_release_notes:
|
||||
|
||||
The ``caplop.records`` fixture contains records from the current stage only. So
|
||||
inside the setup phase it contains only setup logs, same with the call and
|
||||
teardown phases. To access logs from other stages you can use
|
||||
``caplog.get_handler('setup').records``. Valid stages are ``setup``, ``call``
|
||||
and ``teardown``.
|
||||
Release notes
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
This feature was introduced as a drop-in replacement for the `pytest-catchlog
|
||||
<https://pypi.org/project/pytest-catchlog/>`_ plugin and they conflict
|
||||
with each other. The backward compatibility API with ``pytest-capturelog``
|
||||
has been dropped when this feature was introduced, so if for that reason you
|
||||
still need ``pytest-catchlog`` you can disable the internal feature by
|
||||
adding to your ``pytest.ini``:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[pytest]
|
||||
addopts=-p no:logging
|
||||
|
||||
|
||||
.. _log_changes_3_4:
|
||||
|
||||
Incompatible changes in pytest 3.4
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This feature was introduced in ``3.3`` and some **incompatible changes** have been
|
||||
made in ``3.4`` after community feedback:
|
||||
|
||||
* Log levels are no longer changed unless explicitly requested by the :confval:`log_level` configuration
|
||||
or ``--log-level`` command-line options. This allows users to configure logger objects themselves.
|
||||
* :ref:`Live Logs <live_logs>` is now disabled by default and can be enabled setting the
|
||||
:confval:`log_cli` configuration option to ``true``. When enabled, the verbosity is increased so logging for each
|
||||
test is visible.
|
||||
* :ref:`Live Logs <live_logs>` are now sent to ``sys.stdout`` and no longer require the ``-s`` command-line option
|
||||
to work.
|
||||
|
||||
If you want to partially restore the logging behavior of version ``3.3``, you can add this options to your ``ini``
|
||||
file:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[pytest]
|
||||
log_cli=true
|
||||
log_level=NOTSET
|
||||
|
||||
More details about the discussion that lead to this changes can be read in
|
||||
issue `#3013 <https://github.com/pytest-dev/pytest/issues/3013>`_.
|
||||
|
|
|
@ -27,6 +27,30 @@ def test_change_level(caplog):
|
|||
assert 'CRITICAL' in caplog.text
|
||||
|
||||
|
||||
def test_change_level_undo(testdir):
|
||||
"""Ensure that 'set_level' is undone after the end of the test"""
|
||||
testdir.makepyfile('''
|
||||
import logging
|
||||
|
||||
def test1(caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
# using + operator here so fnmatch_lines doesn't match the code in the traceback
|
||||
logging.info('log from ' + 'test1')
|
||||
assert 0
|
||||
|
||||
def test2(caplog):
|
||||
# using + operator here so fnmatch_lines doesn't match the code in the traceback
|
||||
logging.info('log from ' + 'test2')
|
||||
assert 0
|
||||
''')
|
||||
result = testdir.runpytest_subprocess()
|
||||
result.stdout.fnmatch_lines([
|
||||
'*log from test1*',
|
||||
'*2 failed in *',
|
||||
])
|
||||
assert 'log from test2' not in result.stdout.str()
|
||||
|
||||
|
||||
def test_with_statement(caplog):
|
||||
with caplog.at_level(logging.INFO):
|
||||
logger.debug('handler DEBUG level')
|
||||
|
@ -43,6 +67,7 @@ def test_with_statement(caplog):
|
|||
|
||||
|
||||
def test_log_access(caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
logger.info('boo %s', 'arg')
|
||||
assert caplog.records[0].levelname == 'INFO'
|
||||
assert caplog.records[0].msg == 'boo %s'
|
||||
|
@ -50,6 +75,7 @@ def test_log_access(caplog):
|
|||
|
||||
|
||||
def test_record_tuples(caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
logger.info('boo %s', 'arg')
|
||||
|
||||
assert caplog.record_tuples == [
|
||||
|
@ -58,6 +84,7 @@ def test_record_tuples(caplog):
|
|||
|
||||
|
||||
def test_unicode(caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
logger.info(u'bū')
|
||||
assert caplog.records[0].levelname == 'INFO'
|
||||
assert caplog.records[0].msg == u'bū'
|
||||
|
@ -65,6 +92,7 @@ def test_unicode(caplog):
|
|||
|
||||
|
||||
def test_clear(caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
logger.info(u'bū')
|
||||
assert len(caplog.records)
|
||||
caplog.clear()
|
||||
|
@ -73,19 +101,20 @@ def test_clear(caplog):
|
|||
|
||||
@pytest.fixture
|
||||
def logging_during_setup_and_teardown(caplog):
|
||||
caplog.set_level('INFO')
|
||||
logger.info('a_setup_log')
|
||||
yield
|
||||
logger.info('a_teardown_log')
|
||||
assert [x.message for x in caplog.get_handler('teardown').records] == ['a_teardown_log']
|
||||
assert [x.message for x in caplog.get_records('teardown')] == ['a_teardown_log']
|
||||
|
||||
|
||||
def test_caplog_captures_for_all_stages(caplog, logging_during_setup_and_teardown):
|
||||
assert not caplog.records
|
||||
assert not caplog.get_handler('call').records
|
||||
assert not caplog.get_records('call')
|
||||
logger.info('a_call_log')
|
||||
assert [x.message for x in caplog.get_handler('call').records] == ['a_call_log']
|
||||
assert [x.message for x in caplog.get_records('call')] == ['a_call_log']
|
||||
|
||||
assert [x.message for x in caplog.get_handler('setup').records] == ['a_setup_log']
|
||||
assert [x.message for x in caplog.get_records('setup')] == ['a_setup_log']
|
||||
|
||||
# This reachers into private API, don't use this type of thing in real tests!
|
||||
assert set(caplog._item.catch_log_handlers.keys()) == {'setup', 'call'}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
import six
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
@ -35,7 +38,7 @@ def test_messages_logged(testdir):
|
|||
logger.info('text going to logger')
|
||||
assert False
|
||||
''')
|
||||
result = testdir.runpytest()
|
||||
result = testdir.runpytest('--log-level=INFO')
|
||||
assert result.ret == 1
|
||||
result.stdout.fnmatch_lines(['*- Captured *log call -*',
|
||||
'*text going to logger*'])
|
||||
|
@ -58,7 +61,7 @@ def test_setup_logging(testdir):
|
|||
logger.info('text going to logger from call')
|
||||
assert False
|
||||
''')
|
||||
result = testdir.runpytest()
|
||||
result = testdir.runpytest('--log-level=INFO')
|
||||
assert result.ret == 1
|
||||
result.stdout.fnmatch_lines(['*- Captured *log setup -*',
|
||||
'*text going to logger from setup*',
|
||||
|
@ -79,7 +82,7 @@ def test_teardown_logging(testdir):
|
|||
logger.info('text going to logger from teardown')
|
||||
assert False
|
||||
''')
|
||||
result = testdir.runpytest()
|
||||
result = testdir.runpytest('--log-level=INFO')
|
||||
assert result.ret == 1
|
||||
result.stdout.fnmatch_lines(['*- Captured *log call -*',
|
||||
'*text going to logger from call*',
|
||||
|
@ -141,6 +144,30 @@ def test_disable_log_capturing_ini(testdir):
|
|||
result.stdout.fnmatch_lines(['*- Captured *log call -*'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('enabled', [True, False])
|
||||
def test_log_cli_enabled_disabled(testdir, enabled):
|
||||
msg = 'critical message logged by test'
|
||||
testdir.makepyfile('''
|
||||
import logging
|
||||
def test_log_cli():
|
||||
logging.critical("{}")
|
||||
'''.format(msg))
|
||||
if enabled:
|
||||
testdir.makeini('''
|
||||
[pytest]
|
||||
log_cli=true
|
||||
''')
|
||||
result = testdir.runpytest()
|
||||
if enabled:
|
||||
result.stdout.fnmatch_lines([
|
||||
'test_log_cli_enabled_disabled.py::test_log_cli ',
|
||||
'test_log_cli_enabled_disabled.py* CRITICAL critical message logged by test',
|
||||
'PASSED*',
|
||||
])
|
||||
else:
|
||||
assert msg not in result.stdout.str()
|
||||
|
||||
|
||||
def test_log_cli_default_level(testdir):
|
||||
# Default log file level
|
||||
testdir.makepyfile('''
|
||||
|
@ -148,32 +175,103 @@ def test_log_cli_default_level(testdir):
|
|||
import logging
|
||||
def test_log_cli(request):
|
||||
plugin = request.config.pluginmanager.getplugin('logging-plugin')
|
||||
assert plugin.log_cli_handler.level == logging.WARNING
|
||||
logging.getLogger('catchlog').info("This log message won't be shown")
|
||||
logging.getLogger('catchlog').warning("This log message will be shown")
|
||||
print('PASSED')
|
||||
assert plugin.log_cli_handler.level == logging.NOTSET
|
||||
logging.getLogger('catchlog').info("INFO message won't be shown")
|
||||
logging.getLogger('catchlog').warning("WARNING message will be shown")
|
||||
''')
|
||||
testdir.makeini('''
|
||||
[pytest]
|
||||
log_cli=true
|
||||
''')
|
||||
|
||||
result = testdir.runpytest('-s')
|
||||
result = testdir.runpytest()
|
||||
|
||||
# fnmatch_lines does an assertion internally
|
||||
result.stdout.fnmatch_lines([
|
||||
'test_log_cli_default_level.py PASSED',
|
||||
'test_log_cli_default_level.py::test_log_cli ',
|
||||
'test_log_cli_default_level.py*WARNING message will be shown*',
|
||||
])
|
||||
result.stderr.fnmatch_lines([
|
||||
"* This log message will be shown"
|
||||
])
|
||||
for line in result.errlines:
|
||||
try:
|
||||
assert "This log message won't be shown" in line
|
||||
pytest.fail("A log message was shown and it shouldn't have been")
|
||||
except AssertionError:
|
||||
continue
|
||||
|
||||
assert "INFO message won't be shown" not in result.stdout.str()
|
||||
# make sure that that we get a '0' exit code for the testsuite
|
||||
assert result.ret == 0
|
||||
|
||||
|
||||
def test_log_cli_default_level_multiple_tests(testdir, request):
|
||||
"""Ensure we reset the first newline added by the live logger between tests"""
|
||||
filename = request.node.name + '.py'
|
||||
testdir.makepyfile('''
|
||||
import logging
|
||||
|
||||
def test_log_1():
|
||||
logging.warning("log message from test_log_1")
|
||||
|
||||
def test_log_2():
|
||||
logging.warning("log message from test_log_2")
|
||||
''')
|
||||
testdir.makeini('''
|
||||
[pytest]
|
||||
log_cli=true
|
||||
''')
|
||||
|
||||
result = testdir.runpytest()
|
||||
result.stdout.fnmatch_lines([
|
||||
'{}::test_log_1 '.format(filename),
|
||||
'*WARNING*log message from test_log_1*',
|
||||
'PASSED *50%*',
|
||||
'{}::test_log_2 '.format(filename),
|
||||
'*WARNING*log message from test_log_2*',
|
||||
'PASSED *100%*',
|
||||
'=* 2 passed in *=',
|
||||
])
|
||||
|
||||
|
||||
def test_log_cli_default_level_sections(testdir, request):
|
||||
"""Check that with live logging enable we are printing the correct headers during setup/call/teardown."""
|
||||
filename = request.node.name + '.py'
|
||||
testdir.makepyfile('''
|
||||
import pytest
|
||||
import logging
|
||||
|
||||
@pytest.fixture
|
||||
def fix(request):
|
||||
logging.warning("log message from setup of {}".format(request.node.name))
|
||||
yield
|
||||
logging.warning("log message from teardown of {}".format(request.node.name))
|
||||
|
||||
def test_log_1(fix):
|
||||
logging.warning("log message from test_log_1")
|
||||
|
||||
def test_log_2(fix):
|
||||
logging.warning("log message from test_log_2")
|
||||
''')
|
||||
testdir.makeini('''
|
||||
[pytest]
|
||||
log_cli=true
|
||||
''')
|
||||
|
||||
result = testdir.runpytest()
|
||||
result.stdout.fnmatch_lines([
|
||||
'{}::test_log_1 '.format(filename),
|
||||
'*-- live log setup --*',
|
||||
'*WARNING*log message from setup of test_log_1*',
|
||||
'*-- live log call --*',
|
||||
'*WARNING*log message from test_log_1*',
|
||||
'PASSED *50%*',
|
||||
'*-- live log teardown --*',
|
||||
'*WARNING*log message from teardown of test_log_1*',
|
||||
|
||||
'{}::test_log_2 '.format(filename),
|
||||
'*-- live log setup --*',
|
||||
'*WARNING*log message from setup of test_log_2*',
|
||||
'*-- live log call --*',
|
||||
'*WARNING*log message from test_log_2*',
|
||||
'PASSED *100%*',
|
||||
'*-- live log teardown --*',
|
||||
'*WARNING*log message from teardown of test_log_2*',
|
||||
'=* 2 passed in *=',
|
||||
])
|
||||
|
||||
|
||||
def test_log_cli_level(testdir):
|
||||
# Default log file level
|
||||
testdir.makepyfile('''
|
||||
|
@ -186,22 +284,19 @@ def test_log_cli_level(testdir):
|
|||
logging.getLogger('catchlog').info("This log message will be shown")
|
||||
print('PASSED')
|
||||
''')
|
||||
testdir.makeini('''
|
||||
[pytest]
|
||||
log_cli=true
|
||||
''')
|
||||
|
||||
result = testdir.runpytest('-s', '--log-cli-level=INFO')
|
||||
|
||||
# fnmatch_lines does an assertion internally
|
||||
result.stdout.fnmatch_lines([
|
||||
'test_log_cli_level.py PASSED',
|
||||
'test_log_cli_level.py*This log message will be shown',
|
||||
'PASSED', # 'PASSED' on its own line because the log message prints a new line
|
||||
])
|
||||
result.stderr.fnmatch_lines([
|
||||
"* This log message will be shown"
|
||||
])
|
||||
for line in result.errlines:
|
||||
try:
|
||||
assert "This log message won't be shown" in line
|
||||
pytest.fail("A log message was shown and it shouldn't have been")
|
||||
except AssertionError:
|
||||
continue
|
||||
assert "This log message won't be shown" not in result.stdout.str()
|
||||
|
||||
# make sure that that we get a '0' exit code for the testsuite
|
||||
assert result.ret == 0
|
||||
|
@ -210,17 +305,10 @@ def test_log_cli_level(testdir):
|
|||
|
||||
# fnmatch_lines does an assertion internally
|
||||
result.stdout.fnmatch_lines([
|
||||
'test_log_cli_level.py PASSED',
|
||||
'test_log_cli_level.py* This log message will be shown',
|
||||
'PASSED', # 'PASSED' on its own line because the log message prints a new line
|
||||
])
|
||||
result.stderr.fnmatch_lines([
|
||||
"* This log message will be shown"
|
||||
])
|
||||
for line in result.errlines:
|
||||
try:
|
||||
assert "This log message won't be shown" in line
|
||||
pytest.fail("A log message was shown and it shouldn't have been")
|
||||
except AssertionError:
|
||||
continue
|
||||
assert "This log message won't be shown" not in result.stdout.str()
|
||||
|
||||
# make sure that that we get a '0' exit code for the testsuite
|
||||
assert result.ret == 0
|
||||
|
@ -230,6 +318,7 @@ def test_log_cli_ini_level(testdir):
|
|||
testdir.makeini(
|
||||
"""
|
||||
[pytest]
|
||||
log_cli=true
|
||||
log_cli_level = INFO
|
||||
""")
|
||||
testdir.makepyfile('''
|
||||
|
@ -247,17 +336,10 @@ def test_log_cli_ini_level(testdir):
|
|||
|
||||
# fnmatch_lines does an assertion internally
|
||||
result.stdout.fnmatch_lines([
|
||||
'test_log_cli_ini_level.py PASSED',
|
||||
'test_log_cli_ini_level.py* This log message will be shown',
|
||||
'PASSED', # 'PASSED' on its own line because the log message prints a new line
|
||||
])
|
||||
result.stderr.fnmatch_lines([
|
||||
"* This log message will be shown"
|
||||
])
|
||||
for line in result.errlines:
|
||||
try:
|
||||
assert "This log message won't be shown" in line
|
||||
pytest.fail("A log message was shown and it shouldn't have been")
|
||||
except AssertionError:
|
||||
continue
|
||||
assert "This log message won't be shown" not in result.stdout.str()
|
||||
|
||||
# make sure that that we get a '0' exit code for the testsuite
|
||||
assert result.ret == 0
|
||||
|
@ -278,7 +360,7 @@ def test_log_file_cli(testdir):
|
|||
|
||||
log_file = testdir.tmpdir.join('pytest.log').strpath
|
||||
|
||||
result = testdir.runpytest('-s', '--log-file={0}'.format(log_file))
|
||||
result = testdir.runpytest('-s', '--log-file={0}'.format(log_file), '--log-file-level=WARNING')
|
||||
|
||||
# fnmatch_lines does an assertion internally
|
||||
result.stdout.fnmatch_lines([
|
||||
|
@ -327,6 +409,16 @@ def test_log_file_cli_level(testdir):
|
|||
assert "This log message won't be shown" not in contents
|
||||
|
||||
|
||||
def test_log_level_not_changed_by_default(testdir):
|
||||
testdir.makepyfile('''
|
||||
import logging
|
||||
def test_log_file():
|
||||
assert logging.getLogger().level == logging.WARNING
|
||||
''')
|
||||
result = testdir.runpytest('-s')
|
||||
result.stdout.fnmatch_lines('* 1 passed in *')
|
||||
|
||||
|
||||
def test_log_file_ini(testdir):
|
||||
log_file = testdir.tmpdir.join('pytest.log').strpath
|
||||
|
||||
|
@ -334,6 +426,7 @@ def test_log_file_ini(testdir):
|
|||
"""
|
||||
[pytest]
|
||||
log_file={0}
|
||||
log_file_level=WARNING
|
||||
""".format(log_file))
|
||||
testdir.makepyfile('''
|
||||
import pytest
|
||||
|
@ -396,3 +489,53 @@ def test_log_file_ini_level(testdir):
|
|||
contents = rfh.read()
|
||||
assert "This log message will be shown" in contents
|
||||
assert "This log message won't be shown" not in contents
|
||||
|
||||
|
||||
@pytest.mark.parametrize('has_capture_manager', [True, False])
|
||||
def test_live_logging_suspends_capture(has_capture_manager, request):
|
||||
"""Test that capture manager is suspended when we emitting messages for live logging.
|
||||
|
||||
This tests the implementation calls instead of behavior because it is difficult/impossible to do it using
|
||||
``testdir`` facilities because they do their own capturing.
|
||||
|
||||
We parametrize the test to also make sure _LiveLoggingStreamHandler works correctly if no capture manager plugin
|
||||
is installed.
|
||||
"""
|
||||
import logging
|
||||
from functools import partial
|
||||
from _pytest.capture import CaptureManager
|
||||
from _pytest.logging import _LiveLoggingStreamHandler
|
||||
|
||||
class MockCaptureManager:
|
||||
calls = []
|
||||
|
||||
def suspend_global_capture(self):
|
||||
self.calls.append('suspend_global_capture')
|
||||
|
||||
def resume_global_capture(self):
|
||||
self.calls.append('resume_global_capture')
|
||||
|
||||
# sanity check
|
||||
assert CaptureManager.suspend_capture_item
|
||||
assert CaptureManager.resume_global_capture
|
||||
|
||||
class DummyTerminal(six.StringIO):
|
||||
|
||||
def section(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
out_file = DummyTerminal()
|
||||
capture_manager = MockCaptureManager() if has_capture_manager else None
|
||||
handler = _LiveLoggingStreamHandler(out_file, capture_manager)
|
||||
handler.set_when('call')
|
||||
|
||||
logger = logging.getLogger(__name__ + '.test_live_logging_suspends_capture')
|
||||
logger.addHandler(handler)
|
||||
request.addfinalizer(partial(logger.removeHandler, handler))
|
||||
|
||||
logger.critical('some message')
|
||||
if has_capture_manager:
|
||||
assert MockCaptureManager.calls == ['suspend_global_capture', 'resume_global_capture']
|
||||
else:
|
||||
assert MockCaptureManager.calls == []
|
||||
assert out_file.getvalue() == '\nsome message\n'
|
||||
|
|
Loading…
Reference in New Issue