1"""Access and control log capturing."""

2 import io

3 import logging

4 import os

5 import re

6 from contextlib import contextmanager

7 from contextlib import nullcontext

8 from io import StringIO

9 from pathlib import Path

10 from typing import AbstractSet

11 from typing import Dict

12 from typing import Generator

13 from typing import List

14 from typing import Mapping

15 from typing import Optional

16 from typing import Tuple

17 from typing import TYPE_CHECKING

18 from typing import TypeVar

19 from typing import Union

20

21 from _pytest import nodes

22 from _pytest._io import TerminalWriter

23 from _pytest.capture import CaptureManager

24 from _pytest.compat import final

25 from _pytest.config import _strtobool

26 from _pytest.config import Config

27 from _pytest.config import create_terminal_writer

28 from _pytest.config import hookimpl

29 from _pytest.config import UsageError

30 from _pytest.config.argparsing import Parser

31 from _pytest.deprecated import check_ispytest

32 from _pytest.fixtures import fixture

33 from _pytest.fixtures import FixtureRequest

34 from _pytest.main import Session

35 from _pytest.stash import StashKey

36 from _pytest.terminal import TerminalReporter

37

38 if TYPE_CHECKING:

39 logging_StreamHandler = logging.StreamHandler[StringIO]

40

41 from typing_extensions import Literal

42 else:

43 logging_StreamHandler = logging.StreamHandler

44

45 DEFAULT_LOG_FORMAT = "%(levelname)-8s %(name)s:%(filename)s:%(lineno)d %(message)s"

46 DEFAULT_LOG_DATE_FORMAT = "%H:%M:%S"

47_ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m")

48 caplog_handler_key = StashKey["LogCaptureHandler"]()

49 caplog_records_key = StashKey[Dict[str, List[logging.LogRecord]]]()

50

51

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

53 return _ANSI_ESCAPE_SEQ.sub("", text)

54

55

56 class 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

117 class 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

207 def 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

216 def 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.

306 class 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

332 class 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

363 class 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

491 def 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

507 def 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)

532 def pytest_configure(config: Config) -> None:

533 config.pluginmanager.register(LoggingPlugin(config), "logging-plugin")

534

535

536 class 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

752 class _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

760 class _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

819 class _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