22
33import logging
44from contextlib import closing , contextmanager
5- import sys
65import six
76
87import 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
210239def get_actual_log_level (config , * setting_names ):
@@ -234,8 +263,12 @@ def get_actual_log_level(config, *setting_names):
234263
235264
236265def 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
241274class 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 ()
0 commit comments