Skip to content

Commit a580990

Browse files
authored
Merge pull request #3124 from nicoddemus/logging-3013
Changes in the logging plugin for 3.4
2 parents b8be339 + 15cbd61 commit a580990

File tree

6 files changed

+479
-158
lines changed

6 files changed

+479
-158
lines changed

_pytest/logging.py

Lines changed: 154 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import logging
44
from contextlib import closing, contextmanager
5-
import sys
65
import six
76

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

8082

8183
@contextmanager
82-
def catching_logs(handler, formatter=None, level=logging.NOTSET):
84+
def catching_logs(handler, formatter=None, level=None):
8385
"""Context manager that prepares the whole logging machinery properly."""
8486
root_logger = logging.getLogger()
8587

8688
if formatter is not None:
8789
handler.setFormatter(formatter)
88-
handler.setLevel(level)
90+
if level is not None:
91+
handler.setLevel(level)
8992

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

9497
if add_new_handler:
9598
root_logger.addHandler(handler)
96-
orig_level = root_logger.level
97-
root_logger.setLevel(min(orig_level, level))
99+
if level is not None:
100+
orig_level = root_logger.level
101+
root_logger.setLevel(level)
98102
try:
99103
yield handler
100104
finally:
101-
root_logger.setLevel(orig_level)
105+
if level is not None:
106+
root_logger.setLevel(orig_level)
102107
if add_new_handler:
103108
root_logger.removeHandler(handler)
104109

@@ -123,17 +128,39 @@ class LogCaptureFixture(object):
123128
def __init__(self, item):
124129
"""Creates a new funcarg."""
125130
self._item = item
131+
self._initial_log_levels = {} # type: Dict[str, int] # dict of log name -> log level
132+
133+
def _finalize(self):
134+
"""Finalizes the fixture.
135+
136+
This restores the log levels changed by :meth:`set_level`.
137+
"""
138+
# restore log levels
139+
for logger_name, level in self._initial_log_levels.items():
140+
logger = logging.getLogger(logger_name)
141+
logger.setLevel(level)
126142

127143
@property
128144
def handler(self):
129145
return self._item.catch_log_handler
130146

131-
def get_handler(self, when):
147+
def get_records(self, when):
132148
"""
133-
Get the handler for a specified state of the tests.
134-
Valid values for the when parameter are: 'setup', 'call' and 'teardown'.
149+
Get the logging records for one of the possible test phases.
150+
151+
:param str when:
152+
Which test phase to obtain the records from. Valid values are: "setup", "call" and "teardown".
153+
154+
:rtype: List[logging.LogRecord]
155+
:return: the list of captured records at the given stage
156+
157+
.. versionadded:: 3.4
135158
"""
136-
return self._item.catch_log_handlers.get(when)
159+
handler = self._item.catch_log_handlers.get(when)
160+
if handler:
161+
return handler.records
162+
else:
163+
return []
137164

138165
@property
139166
def text(self):
@@ -161,31 +188,31 @@ def clear(self):
161188
self.handler.records = []
162189

163190
def set_level(self, level, logger=None):
164-
"""Sets the level for capturing of logs.
191+
"""Sets the level for capturing of logs. The level will be restored to its previous value at the end of
192+
the test.
165193
166-
By default, the level is set on the handler used to capture
167-
logs. Specify a logger name to instead set the level of any
168-
logger.
194+
:param int level: the logger to level.
195+
:param str logger: the logger to update the level. If not given, the root logger level is updated.
196+
197+
.. versionchanged:: 3.4
198+
The levels of the loggers changed by this function will be restored to their initial values at the
199+
end of the test.
169200
"""
170-
if logger is None:
171-
logger = self.handler
172-
else:
173-
logger = logging.getLogger(logger)
201+
logger_name = logger
202+
logger = logging.getLogger(logger_name)
203+
# save the original log-level to restore it during teardown
204+
self._initial_log_levels.setdefault(logger_name, logger.level)
174205
logger.setLevel(level)
175206

176207
@contextmanager
177208
def at_level(self, level, logger=None):
178-
"""Context manager that sets the level for capturing of logs.
209+
"""Context manager that sets the level for capturing of logs. After the end of the 'with' statement the
210+
level is restored to its original value.
179211
180-
By default, the level is set on the handler used to capture
181-
logs. Specify a logger name to instead set the level of any
182-
logger.
212+
:param int level: the logger to level.
213+
:param str logger: the logger to update the level. If not given, the root logger level is updated.
183214
"""
184-
if logger is None:
185-
logger = self.handler
186-
else:
187-
logger = logging.getLogger(logger)
188-
215+
logger = logging.getLogger(logger)
189216
orig_level = logger.level
190217
logger.setLevel(level)
191218
try:
@@ -204,7 +231,9 @@ def caplog(request):
204231
* caplog.records() -> list of logging.LogRecord instances
205232
* caplog.record_tuples() -> list of (logger_name, level, message) tuples
206233
"""
207-
return LogCaptureFixture(request.node)
234+
result = LogCaptureFixture(request.node)
235+
yield result
236+
result._finalize()
208237

209238

210239
def get_actual_log_level(config, *setting_names):
@@ -234,8 +263,12 @@ def get_actual_log_level(config, *setting_names):
234263

235264

236265
def pytest_configure(config):
237-
config.pluginmanager.register(LoggingPlugin(config),
238-
'logging-plugin')
266+
config.pluginmanager.register(LoggingPlugin(config), 'logging-plugin')
267+
268+
269+
@contextmanager
270+
def _dummy_context_manager():
271+
yield
239272

240273

241274
class LoggingPlugin(object):
@@ -248,52 +281,42 @@ def __init__(self, config):
248281
The formatter can be safely shared across all handlers so
249282
create a single one for the entire test session here.
250283
"""
251-
self.log_cli_level = get_actual_log_level(
252-
config, 'log_cli_level', 'log_level') or logging.WARNING
284+
self._config = config
285+
286+
# enable verbose output automatically if live logging is enabled
287+
if self._config.getini('log_cli') and not config.getoption('verbose'):
288+
# sanity check: terminal reporter should not have been loaded at this point
289+
assert self._config.pluginmanager.get_plugin('terminalreporter') is None
290+
config.option.verbose = 1
253291

254292
self.print_logs = get_option_ini(config, 'log_print')
255-
self.formatter = logging.Formatter(
256-
get_option_ini(config, 'log_format'),
257-
get_option_ini(config, 'log_date_format'))
258-
259-
log_cli_handler = logging.StreamHandler(sys.stderr)
260-
log_cli_format = get_option_ini(
261-
config, 'log_cli_format', 'log_format')
262-
log_cli_date_format = get_option_ini(
263-
config, 'log_cli_date_format', 'log_date_format')
264-
log_cli_formatter = logging.Formatter(
265-
log_cli_format,
266-
datefmt=log_cli_date_format)
267-
self.log_cli_handler = log_cli_handler # needed for a single unittest
268-
self.live_logs = catching_logs(log_cli_handler,
269-
formatter=log_cli_formatter,
270-
level=self.log_cli_level)
293+
self.formatter = logging.Formatter(get_option_ini(config, 'log_format'),
294+
get_option_ini(config, 'log_date_format'))
295+
self.log_level = get_actual_log_level(config, 'log_level')
271296

272297
log_file = get_option_ini(config, 'log_file')
273298
if log_file:
274-
self.log_file_level = get_actual_log_level(
275-
config, 'log_file_level') or logging.WARNING
276-
277-
log_file_format = get_option_ini(
278-
config, 'log_file_format', 'log_format')
279-
log_file_date_format = get_option_ini(
280-
config, 'log_file_date_format', 'log_date_format')
281-
self.log_file_handler = logging.FileHandler(
282-
log_file,
283-
# Each pytest runtests session will write to a clean logfile
284-
mode='w')
285-
log_file_formatter = logging.Formatter(
286-
log_file_format,
287-
datefmt=log_file_date_format)
299+
self.log_file_level = get_actual_log_level(config, 'log_file_level')
300+
301+
log_file_format = get_option_ini(config, 'log_file_format', 'log_format')
302+
log_file_date_format = get_option_ini(config, 'log_file_date_format', 'log_date_format')
303+
# Each pytest runtests session will write to a clean logfile
304+
self.log_file_handler = logging.FileHandler(log_file, mode='w')
305+
log_file_formatter = logging.Formatter(log_file_format, datefmt=log_file_date_format)
288306
self.log_file_handler.setFormatter(log_file_formatter)
289307
else:
290308
self.log_file_handler = None
291309

310+
# initialized during pytest_runtestloop
311+
self.log_cli_handler = None
312+
292313
@contextmanager
293314
def _runtest_for(self, item, when):
294315
"""Implements the internals of pytest_runtest_xxx() hook."""
295316
with catching_logs(LogCaptureHandler(),
296-
formatter=self.formatter) as log_handler:
317+
formatter=self.formatter, level=self.log_level) as log_handler:
318+
if self.log_cli_handler:
319+
self.log_cli_handler.set_when(when)
297320
if not hasattr(item, 'catch_log_handlers'):
298321
item.catch_log_handlers = {}
299322
item.catch_log_handlers[when] = log_handler
@@ -325,14 +348,81 @@ def pytest_runtest_teardown(self, item):
325348
with self._runtest_for(item, 'teardown'):
326349
yield
327350

351+
def pytest_runtest_logstart(self):
352+
if self.log_cli_handler:
353+
self.log_cli_handler.reset()
354+
328355
@pytest.hookimpl(hookwrapper=True)
329356
def pytest_runtestloop(self, session):
330357
"""Runs all collected test items."""
331-
with self.live_logs:
358+
self._setup_cli_logging()
359+
with self.live_logs_context:
332360
if self.log_file_handler is not None:
333361
with closing(self.log_file_handler):
334362
with catching_logs(self.log_file_handler,
335363
level=self.log_file_level):
336364
yield # run all the tests
337365
else:
338366
yield # run all the tests
367+
368+
def _setup_cli_logging(self):
369+
"""Sets up the handler and logger for the Live Logs feature, if enabled.
370+
371+
This must be done right before starting the loop so we can access the terminal reporter plugin.
372+
"""
373+
terminal_reporter = self._config.pluginmanager.get_plugin('terminalreporter')
374+
if self._config.getini('log_cli') and terminal_reporter is not None:
375+
capture_manager = self._config.pluginmanager.get_plugin('capturemanager')
376+
log_cli_handler = _LiveLoggingStreamHandler(terminal_reporter, capture_manager)
377+
log_cli_format = get_option_ini(self._config, 'log_cli_format', 'log_format')
378+
log_cli_date_format = get_option_ini(self._config, 'log_cli_date_format', 'log_date_format')
379+
log_cli_formatter = logging.Formatter(log_cli_format, datefmt=log_cli_date_format)
380+
log_cli_level = get_actual_log_level(self._config, 'log_cli_level', 'log_level')
381+
self.log_cli_handler = log_cli_handler
382+
self.live_logs_context = catching_logs(log_cli_handler, formatter=log_cli_formatter, level=log_cli_level)
383+
else:
384+
self.live_logs_context = _dummy_context_manager()
385+
386+
387+
class _LiveLoggingStreamHandler(logging.StreamHandler):
388+
"""
389+
Custom StreamHandler used by the live logging feature: it will write a newline before the first log message
390+
in each test.
391+
392+
During live logging we must also explicitly disable stdout/stderr capturing otherwise it will get captured
393+
and won't appear in the terminal.
394+
"""
395+
396+
def __init__(self, terminal_reporter, capture_manager):
397+
"""
398+
:param _pytest.terminal.TerminalReporter terminal_reporter:
399+
:param _pytest.capture.CaptureManager capture_manager:
400+
"""
401+
logging.StreamHandler.__init__(self, stream=terminal_reporter)
402+
self.capture_manager = capture_manager
403+
self.reset()
404+
self.set_when(None)
405+
406+
def reset(self):
407+
"""Reset the handler; should be called before the start of each test"""
408+
self._first_record_emitted = False
409+
410+
def set_when(self, when):
411+
"""Prepares for the given test phase (setup/call/teardown)"""
412+
self._when = when
413+
self._section_name_shown = False
414+
415+
def emit(self, record):
416+
if self.capture_manager is not None:
417+
self.capture_manager.suspend_global_capture()
418+
try:
419+
if not self._first_record_emitted or self._when == 'teardown':
420+
self.stream.write('\n')
421+
self._first_record_emitted = True
422+
if not self._section_name_shown:
423+
self.stream.section('live log ' + self._when, sep='-', bold=True)
424+
self._section_name_shown = True
425+
logging.StreamHandler.emit(self, record)
426+
finally:
427+
if self.capture_manager is not None:
428+
self.capture_manager.resume_global_capture()

changelog/3013.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

changelog/3117.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +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.
1+
New ``caplog.get_records(when)`` method which provides access the captured records during each testing stage: ``"setup"``, ``"call"`` and ``"teardown"`` stages.

0 commit comments

Comments
 (0)