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

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 

20 

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 

37 

38if TYPE_CHECKING: 

39 logging_StreamHandler = logging.StreamHandler[StringIO] 

40 

41 from typing_extensions import Literal 

42else: 

43 logging_StreamHandler = logging.StreamHandler 

44 

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]]]() 

50 

51 

52def _remove_ansi_escape_sequences(text: str) -> str: 

53 return _ANSI_ESCAPE_SEQ.sub("", text) 

54 

55 

56class ColoredLevelFormatter(logging.Formatter): 

57 """A logging formatter which colorizes the %(levelname)..s part of the 

58 log format passed to __init__.""" 

59 

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

70 

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] = {} 

76 

77 for level, color_opts in self.LOGLEVEL_COLOROPTS.items(): 

78 self.add_color_level(level, *color_opts) 

79 

80 def add_color_level(self, level: int, *color_opts: str) -> None: 

81 """Add or update color opts for a log level. 

82 

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. 

89 

90 .. warning:: 

91 This is an experimental API. 

92 """ 

93 

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

99 

100 formatted_levelname = levelname_fmt % {"levelname": logging.getLevelName(level)} 

101 

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 ) 

110 

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) 

115 

116 

117class PercentStyleMultiline(logging.PercentStyle): 

118 """A logging style with special support for multiline messages. 

119 

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 """ 

123 

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) 

127 

128 @staticmethod 

129 def _get_auto_indent(auto_indent_option: Union[int, str, bool, None]) -> int: 

130 """Determine the current auto indentation setting. 

131 

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. 

136 

137 Default behavior is auto-indent off. 

138 

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. 

144 

145 Any other values for the option are invalid, and will silently be 

146 converted to the default. 

147 

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. 

153 

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 """ 

160 

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 

180 

181 return 0 

182 

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 

190 

191 if auto_indent: 

192 lines = record.message.splitlines() 

193 formatted = self._fmt % {**record.__dict__, "message": lines[0]} 

194 

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__ 

205 

206 

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 

214 

215 

216def pytest_addoption(parser: Parser) -> None: 

217 """Add options to control log capturing.""" 

218 group = parser.getgroup("logging") 

219 

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) 

225 

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 ) 

300 

301 

302_HandlerType = TypeVar("_HandlerType", bound=logging.Handler) 

303 

304 

305# Not using @contextmanager for performance reasons. 

306class catching_logs: 

307 """Context manager that prepares the whole logging machinery properly.""" 

308 

309 __slots__ = ("handler", "level", "orig_level") 

310 

311 def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None: 

312 self.handler = handler 

313 self.level = level 

314 

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 

324 

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) 

330 

331 

332class LogCaptureHandler(logging_StreamHandler): 

333 """A logging handler that stores log records and the log text.""" 

334 

335 def __init__(self) -> None: 

336 """Create a new log handler.""" 

337 super().__init__(StringIO()) 

338 self.records: List[logging.LogRecord] = [] 

339 

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) 

344 

345 def reset(self) -> None: 

346 self.records = [] 

347 self.stream = StringIO() 

348 

349 def clear(self) -> None: 

350 self.records.clear() 

351 self.stream = StringIO() 

352 

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 

360 

361 

362@final 

363class LogCaptureFixture: 

364 """Provides access and control of log capturing.""" 

365 

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] = {} 

372 

373 def _finalize(self) -> None: 

374 """Finalize the fixture. 

375 

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) 

384 

385 @property 

386 def handler(self) -> LogCaptureHandler: 

387 """Get the logging handler used by the fixture.""" 

388 return self._item.stash[caplog_handler_key] 

389 

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. 

394 

395 :param when: 

396 Which test phase to obtain the records from. 

397 Valid values are: "setup", "call" and "teardown". 

398 

399 :returns: The list of captured records at the given stage. 

400 

401 .. versionadded:: 3.4 

402 """ 

403 return self._item.stash[caplog_records_key].get(when, []) 

404 

405 @property 

406 def text(self) -> str: 

407 """The formatted log text.""" 

408 return _remove_ansi_escape_sequences(self.handler.stream.getvalue()) 

409 

410 @property 

411 def records(self) -> List[logging.LogRecord]: 

412 """The list of log records.""" 

413 return self.handler.records 

414 

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. 

419 

420 The format of the tuple is: 

421 

422 (logger_name, log_level, message) 

423 """ 

424 return [(r.name, r.levelno, r.getMessage()) for r in self.records] 

425 

426 @property 

427 def messages(self) -> List[str]: 

428 """A list of format-interpolated log messages. 

429 

430 Unlike 'records', which contains the format string and parameters for 

431 interpolation, log messages in this list are all interpolated. 

432 

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. 

436 

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. 

440 

441 .. versionadded:: 3.7 

442 """ 

443 return [r.getMessage() for r in self.records] 

444 

445 def clear(self) -> None: 

446 """Reset the list of log records and the captured log text.""" 

447 self.handler.clear() 

448 

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. 

451 

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. 

455 

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) 

466 

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. 

474 

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) 

488 

489 

490@fixture 

491def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]: 

492 """Access and control log capturing. 

493 

494 Captured logs are available through the following properties/methods:: 

495 

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

505 

506 

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 

516 

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 

528 

529 

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

534 

535 

536class LoggingPlugin: 

537 """Attaches to the logging module and captures log messages for each test.""" 

538 

539 def __init__(self, config: Config) -> None: 

540 """Create a new plugin to capture log messages. 

541 

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 

546 

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) 

558 

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) 

566 

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 ) 

572 

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) 

577 

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) 

597 

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) 

609 

610 formatter._style = PercentStyleMultiline( 

611 formatter._style._fmt, auto_indent=auto_indent 

612 ) 

613 

614 return formatter 

615 

616 def set_log_path(self, fname: str) -> None: 

617 """Set the filename parameter for Logging.FileHandler(). 

618 

619 Creates parent directory if it does not exist. 

620 

621 .. warning:: 

622 This is an experimental API. 

623 """ 

624 fpath = Path(fname) 

625 

626 if not fpath.is_absolute(): 

627 fpath = self._config.rootpath / fpath 

628 

629 if not fpath.parent.exists(): 

630 fpath.parent.mkdir(exist_ok=True, parents=True) 

631 

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

637 

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 

645 

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 

650 

651 return True 

652 

653 @hookimpl(hookwrapper=True, tryfirst=True) 

654 def pytest_sessionstart(self) -> Generator[None, None, None]: 

655 self.log_cli_handler.set_when("sessionstart") 

656 

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 

660 

661 @hookimpl(hookwrapper=True, tryfirst=True) 

662 def pytest_collection(self) -> Generator[None, None, None]: 

663 self.log_cli_handler.set_when("collection") 

664 

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 

668 

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 

674 

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 

678 

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. 

682 

683 @hookimpl 

684 def pytest_runtest_logstart(self) -> None: 

685 self.log_cli_handler.reset() 

686 self.log_cli_handler.set_when("start") 

687 

688 @hookimpl 

689 def pytest_runtest_logreport(self) -> None: 

690 self.log_cli_handler.set_when("logreport") 

691 

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 

705 

706 yield 

707 

708 log = report_handler.stream.getvalue().strip() 

709 item.add_report_section(when, "log", log) 

710 

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

714 

715 empty: Dict[str, List[logging.LogRecord]] = {} 

716 item.stash[caplog_records_key] = empty 

717 yield from self._runtest_for(item, "setup") 

718 

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

722 

723 yield from self._runtest_for(item, "call") 

724 

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

728 

729 yield from self._runtest_for(item, "teardown") 

730 del item.stash[caplog_records_key] 

731 del item.stash[caplog_handler_key] 

732 

733 @hookimpl 

734 def pytest_runtest_logfinish(self) -> None: 

735 self.log_cli_handler.set_when("finish") 

736 

737 @hookimpl(hookwrapper=True, tryfirst=True) 

738 def pytest_sessionfinish(self) -> Generator[None, None, None]: 

739 self.log_cli_handler.set_when("sessionfinish") 

740 

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 

744 

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

750 

751 

752class _FileHandler(logging.FileHandler): 

753 """A logging FileHandler with pytest tweaks.""" 

754 

755 def handleError(self, record: logging.LogRecord) -> None: 

756 # Handled by LogCaptureHandler. 

757 pass 

758 

759 

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. 

763 

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 """ 

768 

769 # Officially stream needs to be a IO[str], but TerminalReporter 

770 # isn't. So force it. 

771 stream: TerminalReporter = None # type: ignore 

772 

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 

783 

784 def reset(self) -> None: 

785 """Reset the handler; should be called before the start of each test.""" 

786 self._first_record_emitted = False 

787 

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 

794 

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) 

813 

814 def handleError(self, record: logging.LogRecord) -> None: 

815 # Handled by LogCaptureHandler. 

816 pass 

817 

818 

819class _LiveLoggingNullHandler(logging.NullHandler): 

820 """A logging handler used when live logging is disabled.""" 

821 

822 def reset(self) -> None: 

823 pass 

824 

825 def set_when(self, when: str) -> None: 

826 pass 

827 

828 def handleError(self, record: logging.LogRecord) -> None: 

829 # Handled by LogCaptureHandler. 

830 pass