Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/logging.py: 66%
400 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
1"""Access and control log capturing."""
2import io
3import logging
4import os
5import re
6from contextlib import contextmanager
7from contextlib import nullcontext
8from io import StringIO
9from pathlib import Path
10from typing import AbstractSet
11from typing import Dict
12from typing import Generator
13from typing import List
14from typing import Mapping
15from typing import Optional
16from typing import Tuple
17from typing import TYPE_CHECKING
18from typing import TypeVar
19from typing import Union
21from _pytest import nodes
22from _pytest._io import TerminalWriter
23from _pytest.capture import CaptureManager
24from _pytest.compat import final
25from _pytest.config import _strtobool
26from _pytest.config import Config
27from _pytest.config import create_terminal_writer
28from _pytest.config import hookimpl
29from _pytest.config import UsageError
30from _pytest.config.argparsing import Parser
31from _pytest.deprecated import check_ispytest
32from _pytest.fixtures import fixture
33from _pytest.fixtures import FixtureRequest
34from _pytest.main import Session
35from _pytest.stash import StashKey
36from _pytest.terminal import TerminalReporter
38if TYPE_CHECKING:
39 logging_StreamHandler = logging.StreamHandler[StringIO]
41 from typing_extensions import Literal
42else:
43 logging_StreamHandler = logging.StreamHandler
45DEFAULT_LOG_FORMAT = "%(levelname)-8s %(name)s:%(filename)s:%(lineno)d %(message)s"
46DEFAULT_LOG_DATE_FORMAT = "%H:%M:%S"
47_ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m")
48caplog_handler_key = StashKey["LogCaptureHandler"]()
49caplog_records_key = StashKey[Dict[str, List[logging.LogRecord]]]()
52def _remove_ansi_escape_sequences(text: str) -> str:
53 return _ANSI_ESCAPE_SEQ.sub("", text)
56class ColoredLevelFormatter(logging.Formatter):
57 """A logging formatter which colorizes the %(levelname)..s part of the
58 log format passed to __init__."""
60 LOGLEVEL_COLOROPTS: Mapping[int, AbstractSet[str]] = {
61 logging.CRITICAL: {"red"},
62 logging.ERROR: {"red", "bold"},
63 logging.WARNING: {"yellow"},
64 logging.WARN: {"yellow"},
65 logging.INFO: {"green"},
66 logging.DEBUG: {"purple"},
67 logging.NOTSET: set(),
68 }
69 LEVELNAME_FMT_REGEX = re.compile(r"%\(levelname\)([+-.]?\d*(?:\.\d+)?s)")
71 def __init__(self, terminalwriter: TerminalWriter, *args, **kwargs) -> None:
72 super().__init__(*args, **kwargs)
73 self._terminalwriter = terminalwriter
74 self._original_fmt = self._style._fmt
75 self._level_to_fmt_mapping: Dict[int, str] = {}
77 for level, color_opts in self.LOGLEVEL_COLOROPTS.items():
78 self.add_color_level(level, *color_opts)
80 def add_color_level(self, level: int, *color_opts: str) -> None:
81 """Add or update color opts for a log level.
83 :param level:
84 Log level to apply a style to, e.g. ``logging.INFO``.
85 :param color_opts:
86 ANSI escape sequence color options. Capitalized colors indicates
87 background color, i.e. ``'green', 'Yellow', 'bold'`` will give bold
88 green text on yellow background.
90 .. warning::
91 This is an experimental API.
92 """
94 assert self._fmt is not None
95 levelname_fmt_match = self.LEVELNAME_FMT_REGEX.search(self._fmt)
96 if not levelname_fmt_match:
97 return
98 levelname_fmt = levelname_fmt_match.group()
100 formatted_levelname = levelname_fmt % {"levelname": logging.getLevelName(level)}
102 # add ANSI escape sequences around the formatted levelname
103 color_kwargs = {name: True for name in color_opts}
104 colorized_formatted_levelname = self._terminalwriter.markup(
105 formatted_levelname, **color_kwargs
106 )
107 self._level_to_fmt_mapping[level] = self.LEVELNAME_FMT_REGEX.sub(
108 colorized_formatted_levelname, self._fmt
109 )
111 def format(self, record: logging.LogRecord) -> str:
112 fmt = self._level_to_fmt_mapping.get(record.levelno, self._original_fmt)
113 self._style._fmt = fmt
114 return super().format(record)
117class PercentStyleMultiline(logging.PercentStyle):
118 """A logging style with special support for multiline messages.
120 If the message of a record consists of multiple lines, this style
121 formats the message as if each line were logged separately.
122 """
124 def __init__(self, fmt: str, auto_indent: Union[int, str, bool, None]) -> None:
125 super().__init__(fmt)
126 self._auto_indent = self._get_auto_indent(auto_indent)
128 @staticmethod
129 def _get_auto_indent(auto_indent_option: Union[int, str, bool, None]) -> int:
130 """Determine the current auto indentation setting.
132 Specify auto indent behavior (on/off/fixed) by passing in
133 extra={"auto_indent": [value]} to the call to logging.log() or
134 using a --log-auto-indent [value] command line or the
135 log_auto_indent [value] config option.
137 Default behavior is auto-indent off.
139 Using the string "True" or "on" or the boolean True as the value
140 turns auto indent on, using the string "False" or "off" or the
141 boolean False or the int 0 turns it off, and specifying a
142 positive integer fixes the indentation position to the value
143 specified.
145 Any other values for the option are invalid, and will silently be
146 converted to the default.
148 :param None|bool|int|str auto_indent_option:
149 User specified option for indentation from command line, config
150 or extra kwarg. Accepts int, bool or str. str option accepts the
151 same range of values as boolean config options, as well as
152 positive integers represented in str form.
154 :returns:
155 Indentation value, which can be
156 -1 (automatically determine indentation) or
157 0 (auto-indent turned off) or
158 >0 (explicitly set indentation position).
159 """
161 if auto_indent_option is None:
162 return 0
163 elif isinstance(auto_indent_option, bool):
164 if auto_indent_option:
165 return -1
166 else:
167 return 0
168 elif isinstance(auto_indent_option, int):
169 return int(auto_indent_option)
170 elif isinstance(auto_indent_option, str):
171 try:
172 return int(auto_indent_option)
173 except ValueError:
174 pass
175 try:
176 if _strtobool(auto_indent_option):
177 return -1
178 except ValueError:
179 return 0
181 return 0
183 def format(self, record: logging.LogRecord) -> str:
184 if "\n" in record.message:
185 if hasattr(record, "auto_indent"):
186 # Passed in from the "extra={}" kwarg on the call to logging.log().
187 auto_indent = self._get_auto_indent(record.auto_indent) # type: ignore[attr-defined]
188 else:
189 auto_indent = self._auto_indent
191 if auto_indent:
192 lines = record.message.splitlines()
193 formatted = self._fmt % {**record.__dict__, "message": lines[0]}
195 if auto_indent < 0:
196 indentation = _remove_ansi_escape_sequences(formatted).find(
197 lines[0]
198 )
199 else:
200 # Optimizes logging by allowing a fixed indentation.
201 indentation = auto_indent
202 lines[0] = formatted
203 return ("\n" + " " * indentation).join(lines)
204 return self._fmt % record.__dict__
207def get_option_ini(config: Config, *names: str):
208 for name in names:
209 ret = config.getoption(name) # 'default' arg won't work as expected
210 if ret is None:
211 ret = config.getini(name)
212 if ret:
213 return ret
216def pytest_addoption(parser: Parser) -> None:
217 """Add options to control log capturing."""
218 group = parser.getgroup("logging")
220 def add_option_ini(option, dest, default=None, type=None, **kwargs):
221 parser.addini(
222 dest, default=default, type=type, help="Default value for " + option
223 )
224 group.addoption(option, dest=dest, **kwargs)
226 add_option_ini(
227 "--log-level",
228 dest="log_level",
229 default=None,
230 metavar="LEVEL",
231 help=(
232 "Level of messages to catch/display."
233 " Not set by default, so it depends on the root/parent log handler's"
234 ' effective level, where it is "WARNING" by default.'
235 ),
236 )
237 add_option_ini(
238 "--log-format",
239 dest="log_format",
240 default=DEFAULT_LOG_FORMAT,
241 help="Log format used by the logging module",
242 )
243 add_option_ini(
244 "--log-date-format",
245 dest="log_date_format",
246 default=DEFAULT_LOG_DATE_FORMAT,
247 help="Log date format used by the logging module",
248 )
249 parser.addini(
250 "log_cli",
251 default=False,
252 type="bool",
253 help='Enable log display during test run (also known as "live logging")',
254 )
255 add_option_ini(
256 "--log-cli-level", dest="log_cli_level", default=None, help="CLI logging level"
257 )
258 add_option_ini(
259 "--log-cli-format",
260 dest="log_cli_format",
261 default=None,
262 help="Log format used by the logging module",
263 )
264 add_option_ini(
265 "--log-cli-date-format",
266 dest="log_cli_date_format",
267 default=None,
268 help="Log date format used by the logging module",
269 )
270 add_option_ini(
271 "--log-file",
272 dest="log_file",
273 default=None,
274 help="Path to a file when logging will be written to",
275 )
276 add_option_ini(
277 "--log-file-level",
278 dest="log_file_level",
279 default=None,
280 help="Log file logging level",
281 )
282 add_option_ini(
283 "--log-file-format",
284 dest="log_file_format",
285 default=DEFAULT_LOG_FORMAT,
286 help="Log format used by the logging module",
287 )
288 add_option_ini(
289 "--log-file-date-format",
290 dest="log_file_date_format",
291 default=DEFAULT_LOG_DATE_FORMAT,
292 help="Log date format used by the logging module",
293 )
294 add_option_ini(
295 "--log-auto-indent",
296 dest="log_auto_indent",
297 default=None,
298 help="Auto-indent multiline messages passed to the logging module. Accepts true|on, false|off or an integer.",
299 )
302_HandlerType = TypeVar("_HandlerType", bound=logging.Handler)
305# Not using @contextmanager for performance reasons.
306class catching_logs:
307 """Context manager that prepares the whole logging machinery properly."""
309 __slots__ = ("handler", "level", "orig_level")
311 def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None:
312 self.handler = handler
313 self.level = level
315 def __enter__(self):
316 root_logger = logging.getLogger()
317 if self.level is not None:
318 self.handler.setLevel(self.level)
319 root_logger.addHandler(self.handler)
320 if self.level is not None:
321 self.orig_level = root_logger.level
322 root_logger.setLevel(min(self.orig_level, self.level))
323 return self.handler
325 def __exit__(self, type, value, traceback):
326 root_logger = logging.getLogger()
327 if self.level is not None:
328 root_logger.setLevel(self.orig_level)
329 root_logger.removeHandler(self.handler)
332class LogCaptureHandler(logging_StreamHandler):
333 """A logging handler that stores log records and the log text."""
335 def __init__(self) -> None:
336 """Create a new log handler."""
337 super().__init__(StringIO())
338 self.records: List[logging.LogRecord] = []
340 def emit(self, record: logging.LogRecord) -> None:
341 """Keep the log records in a list in addition to the log text."""
342 self.records.append(record)
343 super().emit(record)
345 def reset(self) -> None:
346 self.records = []
347 self.stream = StringIO()
349 def clear(self) -> None:
350 self.records.clear()
351 self.stream = StringIO()
353 def handleError(self, record: logging.LogRecord) -> None:
354 if logging.raiseExceptions:
355 # Fail the test if the log message is bad (emit failed).
356 # The default behavior of logging is to print "Logging error"
357 # to stderr with the call stack and some extra details.
358 # pytest wants to make such mistakes visible during testing.
359 raise
362@final
363class LogCaptureFixture:
364 """Provides access and control of log capturing."""
366 def __init__(self, item: nodes.Node, *, _ispytest: bool = False) -> None:
367 check_ispytest(_ispytest)
368 self._item = item
369 self._initial_handler_level: Optional[int] = None
370 # Dict of log name -> log level.
371 self._initial_logger_levels: Dict[Optional[str], int] = {}
373 def _finalize(self) -> None:
374 """Finalize the fixture.
376 This restores the log levels changed by :meth:`set_level`.
377 """
378 # Restore log levels.
379 if self._initial_handler_level is not None:
380 self.handler.setLevel(self._initial_handler_level)
381 for logger_name, level in self._initial_logger_levels.items():
382 logger = logging.getLogger(logger_name)
383 logger.setLevel(level)
385 @property
386 def handler(self) -> LogCaptureHandler:
387 """Get the logging handler used by the fixture."""
388 return self._item.stash[caplog_handler_key]
390 def get_records(
391 self, when: "Literal['setup', 'call', 'teardown']"
392 ) -> List[logging.LogRecord]:
393 """Get the logging records for one of the possible test phases.
395 :param when:
396 Which test phase to obtain the records from.
397 Valid values are: "setup", "call" and "teardown".
399 :returns: The list of captured records at the given stage.
401 .. versionadded:: 3.4
402 """
403 return self._item.stash[caplog_records_key].get(when, [])
405 @property
406 def text(self) -> str:
407 """The formatted log text."""
408 return _remove_ansi_escape_sequences(self.handler.stream.getvalue())
410 @property
411 def records(self) -> List[logging.LogRecord]:
412 """The list of log records."""
413 return self.handler.records
415 @property
416 def record_tuples(self) -> List[Tuple[str, int, str]]:
417 """A list of a stripped down version of log records intended
418 for use in assertion comparison.
420 The format of the tuple is:
422 (logger_name, log_level, message)
423 """
424 return [(r.name, r.levelno, r.getMessage()) for r in self.records]
426 @property
427 def messages(self) -> List[str]:
428 """A list of format-interpolated log messages.
430 Unlike 'records', which contains the format string and parameters for
431 interpolation, log messages in this list are all interpolated.
433 Unlike 'text', which contains the output from the handler, log
434 messages in this list are unadorned with levels, timestamps, etc,
435 making exact comparisons more reliable.
437 Note that traceback or stack info (from :func:`logging.exception` or
438 the `exc_info` or `stack_info` arguments to the logging functions) is
439 not included, as this is added by the formatter in the handler.
441 .. versionadded:: 3.7
442 """
443 return [r.getMessage() for r in self.records]
445 def clear(self) -> None:
446 """Reset the list of log records and the captured log text."""
447 self.handler.clear()
449 def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> None:
450 """Set the level of a logger for the duration of a test.
452 .. versionchanged:: 3.4
453 The levels of the loggers changed by this function will be
454 restored to their initial values at the end of the test.
456 :param level: The level.
457 :param logger: The logger to update. If not given, the root logger.
458 """
459 logger_obj = logging.getLogger(logger)
460 # Save the original log-level to restore it during teardown.
461 self._initial_logger_levels.setdefault(logger, logger_obj.level)
462 logger_obj.setLevel(level)
463 if self._initial_handler_level is None:
464 self._initial_handler_level = self.handler.level
465 self.handler.setLevel(level)
467 @contextmanager
468 def at_level(
469 self, level: Union[int, str], logger: Optional[str] = None
470 ) -> Generator[None, None, None]:
471 """Context manager that sets the level for capturing of logs. After
472 the end of the 'with' statement the level is restored to its original
473 value.
475 :param level: The level.
476 :param logger: The logger to update. If not given, the root logger.
477 """
478 logger_obj = logging.getLogger(logger)
479 orig_level = logger_obj.level
480 logger_obj.setLevel(level)
481 handler_orig_level = self.handler.level
482 self.handler.setLevel(level)
483 try:
484 yield
485 finally:
486 logger_obj.setLevel(orig_level)
487 self.handler.setLevel(handler_orig_level)
490@fixture
491def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]:
492 """Access and control log capturing.
494 Captured logs are available through the following properties/methods::
496 * caplog.messages -> list of format-interpolated log messages
497 * caplog.text -> string containing formatted log output
498 * caplog.records -> list of logging.LogRecord instances
499 * caplog.record_tuples -> list of (logger_name, level, message) tuples
500 * caplog.clear() -> clear captured records and formatted log output string
501 """
502 result = LogCaptureFixture(request.node, _ispytest=True)
503 yield result
504 result._finalize()
507def get_log_level_for_setting(config: Config, *setting_names: str) -> Optional[int]:
508 for setting_name in setting_names:
509 log_level = config.getoption(setting_name)
510 if log_level is None:
511 log_level = config.getini(setting_name)
512 if log_level:
513 break
514 else:
515 return None
517 if isinstance(log_level, str):
518 log_level = log_level.upper()
519 try:
520 return int(getattr(logging, log_level, log_level))
521 except ValueError as e:
522 # Python logging does not recognise this as a logging level
523 raise UsageError(
524 "'{}' is not recognized as a logging level name for "
525 "'{}'. Please consider passing the "
526 "logging level num instead.".format(log_level, setting_name)
527 ) from e
530# run after terminalreporter/capturemanager are configured
531@hookimpl(trylast=True)
532def pytest_configure(config: Config) -> None:
533 config.pluginmanager.register(LoggingPlugin(config), "logging-plugin")
536class LoggingPlugin:
537 """Attaches to the logging module and captures log messages for each test."""
539 def __init__(self, config: Config) -> None:
540 """Create a new plugin to capture log messages.
542 The formatter can be safely shared across all handlers so
543 create a single one for the entire test session here.
544 """
545 self._config = config
547 # Report logging.
548 self.formatter = self._create_formatter(
549 get_option_ini(config, "log_format"),
550 get_option_ini(config, "log_date_format"),
551 get_option_ini(config, "log_auto_indent"),
552 )
553 self.log_level = get_log_level_for_setting(config, "log_level")
554 self.caplog_handler = LogCaptureHandler()
555 self.caplog_handler.setFormatter(self.formatter)
556 self.report_handler = LogCaptureHandler()
557 self.report_handler.setFormatter(self.formatter)
559 # File logging.
560 self.log_file_level = get_log_level_for_setting(config, "log_file_level")
561 log_file = get_option_ini(config, "log_file") or os.devnull
562 if log_file != os.devnull:
563 directory = os.path.dirname(os.path.abspath(log_file))
564 if not os.path.isdir(directory):
565 os.makedirs(directory)
567 self.log_file_handler = _FileHandler(log_file, mode="w", encoding="UTF-8")
568 log_file_format = get_option_ini(config, "log_file_format", "log_format")
569 log_file_date_format = get_option_ini(
570 config, "log_file_date_format", "log_date_format"
571 )
573 log_file_formatter = logging.Formatter(
574 log_file_format, datefmt=log_file_date_format
575 )
576 self.log_file_handler.setFormatter(log_file_formatter)
578 # CLI/live logging.
579 self.log_cli_level = get_log_level_for_setting(
580 config, "log_cli_level", "log_level"
581 )
582 if self._log_cli_enabled():
583 terminal_reporter = config.pluginmanager.get_plugin("terminalreporter")
584 capture_manager = config.pluginmanager.get_plugin("capturemanager")
585 # if capturemanager plugin is disabled, live logging still works.
586 self.log_cli_handler: Union[
587 _LiveLoggingStreamHandler, _LiveLoggingNullHandler
588 ] = _LiveLoggingStreamHandler(terminal_reporter, capture_manager)
589 else:
590 self.log_cli_handler = _LiveLoggingNullHandler()
591 log_cli_formatter = self._create_formatter(
592 get_option_ini(config, "log_cli_format", "log_format"),
593 get_option_ini(config, "log_cli_date_format", "log_date_format"),
594 get_option_ini(config, "log_auto_indent"),
595 )
596 self.log_cli_handler.setFormatter(log_cli_formatter)
598 def _create_formatter(self, log_format, log_date_format, auto_indent):
599 # Color option doesn't exist if terminal plugin is disabled.
600 color = getattr(self._config.option, "color", "no")
601 if color != "no" and ColoredLevelFormatter.LEVELNAME_FMT_REGEX.search(
602 log_format
603 ):
604 formatter: logging.Formatter = ColoredLevelFormatter(
605 create_terminal_writer(self._config), log_format, log_date_format
606 )
607 else:
608 formatter = logging.Formatter(log_format, log_date_format)
610 formatter._style = PercentStyleMultiline(
611 formatter._style._fmt, auto_indent=auto_indent
612 )
614 return formatter
616 def set_log_path(self, fname: str) -> None:
617 """Set the filename parameter for Logging.FileHandler().
619 Creates parent directory if it does not exist.
621 .. warning::
622 This is an experimental API.
623 """
624 fpath = Path(fname)
626 if not fpath.is_absolute():
627 fpath = self._config.rootpath / fpath
629 if not fpath.parent.exists():
630 fpath.parent.mkdir(exist_ok=True, parents=True)
632 # https://github.com/python/mypy/issues/11193
633 stream: io.TextIOWrapper = fpath.open(mode="w", encoding="UTF-8") # type: ignore[assignment]
634 old_stream = self.log_file_handler.setStream(stream)
635 if old_stream:
636 old_stream.close()
638 def _log_cli_enabled(self):
639 """Return whether live logging is enabled."""
640 enabled = self._config.getoption(
641 "--log-cli-level"
642 ) is not None or self._config.getini("log_cli")
643 if not enabled:
644 return False
646 terminal_reporter = self._config.pluginmanager.get_plugin("terminalreporter")
647 if terminal_reporter is None:
648 # terminal reporter is disabled e.g. by pytest-xdist.
649 return False
651 return True
653 @hookimpl(hookwrapper=True, tryfirst=True)
654 def pytest_sessionstart(self) -> Generator[None, None, None]:
655 self.log_cli_handler.set_when("sessionstart")
657 with catching_logs(self.log_cli_handler, level=self.log_cli_level):
658 with catching_logs(self.log_file_handler, level=self.log_file_level):
659 yield
661 @hookimpl(hookwrapper=True, tryfirst=True)
662 def pytest_collection(self) -> Generator[None, None, None]:
663 self.log_cli_handler.set_when("collection")
665 with catching_logs(self.log_cli_handler, level=self.log_cli_level):
666 with catching_logs(self.log_file_handler, level=self.log_file_level):
667 yield
669 @hookimpl(hookwrapper=True)
670 def pytest_runtestloop(self, session: Session) -> Generator[None, None, None]:
671 if session.config.option.collectonly:
672 yield
673 return
675 if self._log_cli_enabled() and self._config.getoption("verbose") < 1:
676 # The verbose flag is needed to avoid messy test progress output.
677 self._config.option.verbose = 1
679 with catching_logs(self.log_cli_handler, level=self.log_cli_level):
680 with catching_logs(self.log_file_handler, level=self.log_file_level):
681 yield # Run all the tests.
683 @hookimpl
684 def pytest_runtest_logstart(self) -> None:
685 self.log_cli_handler.reset()
686 self.log_cli_handler.set_when("start")
688 @hookimpl
689 def pytest_runtest_logreport(self) -> None:
690 self.log_cli_handler.set_when("logreport")
692 def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, None]:
693 """Implement the internals of the pytest_runtest_xxx() hooks."""
694 with catching_logs(
695 self.caplog_handler,
696 level=self.log_level,
697 ) as caplog_handler, catching_logs(
698 self.report_handler,
699 level=self.log_level,
700 ) as report_handler:
701 caplog_handler.reset()
702 report_handler.reset()
703 item.stash[caplog_records_key][when] = caplog_handler.records
704 item.stash[caplog_handler_key] = caplog_handler
706 yield
708 log = report_handler.stream.getvalue().strip()
709 item.add_report_section(when, "log", log)
711 @hookimpl(hookwrapper=True)
712 def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]:
713 self.log_cli_handler.set_when("setup")
715 empty: Dict[str, List[logging.LogRecord]] = {}
716 item.stash[caplog_records_key] = empty
717 yield from self._runtest_for(item, "setup")
719 @hookimpl(hookwrapper=True)
720 def pytest_runtest_call(self, item: nodes.Item) -> Generator[None, None, None]:
721 self.log_cli_handler.set_when("call")
723 yield from self._runtest_for(item, "call")
725 @hookimpl(hookwrapper=True)
726 def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, None]:
727 self.log_cli_handler.set_when("teardown")
729 yield from self._runtest_for(item, "teardown")
730 del item.stash[caplog_records_key]
731 del item.stash[caplog_handler_key]
733 @hookimpl
734 def pytest_runtest_logfinish(self) -> None:
735 self.log_cli_handler.set_when("finish")
737 @hookimpl(hookwrapper=True, tryfirst=True)
738 def pytest_sessionfinish(self) -> Generator[None, None, None]:
739 self.log_cli_handler.set_when("sessionfinish")
741 with catching_logs(self.log_cli_handler, level=self.log_cli_level):
742 with catching_logs(self.log_file_handler, level=self.log_file_level):
743 yield
745 @hookimpl
746 def pytest_unconfigure(self) -> None:
747 # Close the FileHandler explicitly.
748 # (logging.shutdown might have lost the weakref?!)
749 self.log_file_handler.close()
752class _FileHandler(logging.FileHandler):
753 """A logging FileHandler with pytest tweaks."""
755 def handleError(self, record: logging.LogRecord) -> None:
756 # Handled by LogCaptureHandler.
757 pass
760class _LiveLoggingStreamHandler(logging_StreamHandler):
761 """A logging StreamHandler used by the live logging feature: it will
762 write a newline before the first log message in each test.
764 During live logging we must also explicitly disable stdout/stderr
765 capturing otherwise it will get captured and won't appear in the
766 terminal.
767 """
769 # Officially stream needs to be a IO[str], but TerminalReporter
770 # isn't. So force it.
771 stream: TerminalReporter = None # type: ignore
773 def __init__(
774 self,
775 terminal_reporter: TerminalReporter,
776 capture_manager: Optional[CaptureManager],
777 ) -> None:
778 super().__init__(stream=terminal_reporter) # type: ignore[arg-type]
779 self.capture_manager = capture_manager
780 self.reset()
781 self.set_when(None)
782 self._test_outcome_written = False
784 def reset(self) -> None:
785 """Reset the handler; should be called before the start of each test."""
786 self._first_record_emitted = False
788 def set_when(self, when: Optional[str]) -> None:
789 """Prepare for the given test phase (setup/call/teardown)."""
790 self._when = when
791 self._section_name_shown = False
792 if when == "start":
793 self._test_outcome_written = False
795 def emit(self, record: logging.LogRecord) -> None:
796 ctx_manager = (
797 self.capture_manager.global_and_fixture_disabled()
798 if self.capture_manager
799 else nullcontext()
800 )
801 with ctx_manager:
802 if not self._first_record_emitted:
803 self.stream.write("\n")
804 self._first_record_emitted = True
805 elif self._when in ("teardown", "finish"):
806 if not self._test_outcome_written:
807 self._test_outcome_written = True
808 self.stream.write("\n")
809 if not self._section_name_shown and self._when:
810 self.stream.section("live log " + self._when, sep="-", bold=True)
811 self._section_name_shown = True
812 super().emit(record)
814 def handleError(self, record: logging.LogRecord) -> None:
815 # Handled by LogCaptureHandler.
816 pass
819class _LiveLoggingNullHandler(logging.NullHandler):
820 """A logging handler used when live logging is disabled."""
822 def reset(self) -> None:
823 pass
825 def set_when(self, when: str) -> None:
826 pass
828 def handleError(self, record: logging.LogRecord) -> None:
829 # Handled by LogCaptureHandler.
830 pass