Handle disabled logging in 'caplog.set_level' and 'caplog.at_level' (#8758)
Forces requested `caplog` logging levels to be enabled if they were disabled via `logging.disable()` `[attr-defined]` mypy error ignored in `logging.py` because there were existing errors with the imports and `loggin.Logger.manager` is an attr set at runtime. Since it's in the standard lib I can't really fix that. Ignored an attr-defined error in `src/_pytest/config/__init__.py` because the re-export is necessary. Fixes #8711
This commit is contained in:
parent
c8641f879f
commit
ba32a3bd87
1
AUTHORS
1
AUTHORS
|
@ -15,6 +15,7 @@ Akiomi Kamakura
|
||||||
Alan Velasco
|
Alan Velasco
|
||||||
Alessio Izzo
|
Alessio Izzo
|
||||||
Alex Jones
|
Alex Jones
|
||||||
|
Alex Lambson
|
||||||
Alexander Johnson
|
Alexander Johnson
|
||||||
Alexander King
|
Alexander King
|
||||||
Alexei Kozlenok
|
Alexei Kozlenok
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
:func:`_pytest.logging.LogCaptureFixture.set_level` and :func:`_pytest.logging.LogCaptureFixture.at_level`
|
||||||
|
will temporarily enable the requested ``level`` if ``level`` was disabled globally via
|
||||||
|
``logging.disable(LEVEL)``.
|
|
@ -49,7 +49,7 @@ from _pytest._code import ExceptionInfo
|
||||||
from _pytest._code import filter_traceback
|
from _pytest._code import filter_traceback
|
||||||
from _pytest._io import TerminalWriter
|
from _pytest._io import TerminalWriter
|
||||||
from _pytest.compat import final
|
from _pytest.compat import final
|
||||||
from _pytest.compat import importlib_metadata
|
from _pytest.compat import importlib_metadata # type: ignore[attr-defined]
|
||||||
from _pytest.outcomes import fail
|
from _pytest.outcomes import fail
|
||||||
from _pytest.outcomes import Skipped
|
from _pytest.outcomes import Skipped
|
||||||
from _pytest.pathlib import absolutepath
|
from _pytest.pathlib import absolutepath
|
||||||
|
|
|
@ -376,11 +376,12 @@ class LogCaptureFixture:
|
||||||
self._initial_handler_level: Optional[int] = None
|
self._initial_handler_level: Optional[int] = None
|
||||||
# Dict of log name -> log level.
|
# Dict of log name -> log level.
|
||||||
self._initial_logger_levels: Dict[Optional[str], int] = {}
|
self._initial_logger_levels: Dict[Optional[str], int] = {}
|
||||||
|
self._initial_disabled_logging_level: Optional[int] = None
|
||||||
|
|
||||||
def _finalize(self) -> None:
|
def _finalize(self) -> None:
|
||||||
"""Finalize the fixture.
|
"""Finalize the fixture.
|
||||||
|
|
||||||
This restores the log levels changed by :meth:`set_level`.
|
This restores the log levels and the disabled logging levels changed by :meth:`set_level`.
|
||||||
"""
|
"""
|
||||||
# Restore log levels.
|
# Restore log levels.
|
||||||
if self._initial_handler_level is not None:
|
if self._initial_handler_level is not None:
|
||||||
|
@ -388,6 +389,10 @@ class LogCaptureFixture:
|
||||||
for logger_name, level in self._initial_logger_levels.items():
|
for logger_name, level in self._initial_logger_levels.items():
|
||||||
logger = logging.getLogger(logger_name)
|
logger = logging.getLogger(logger_name)
|
||||||
logger.setLevel(level)
|
logger.setLevel(level)
|
||||||
|
# Disable logging at the original disabled logging level.
|
||||||
|
if self._initial_disabled_logging_level is not None:
|
||||||
|
logging.disable(self._initial_disabled_logging_level)
|
||||||
|
self._initial_disabled_logging_level = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def handler(self) -> LogCaptureHandler:
|
def handler(self) -> LogCaptureHandler:
|
||||||
|
@ -453,6 +458,40 @@ class LogCaptureFixture:
|
||||||
"""Reset the list of log records and the captured log text."""
|
"""Reset the list of log records and the captured log text."""
|
||||||
self.handler.clear()
|
self.handler.clear()
|
||||||
|
|
||||||
|
def _force_enable_logging(
|
||||||
|
self, level: Union[int, str], logger_obj: logging.Logger
|
||||||
|
) -> int:
|
||||||
|
"""Enable the desired logging level if the global level was disabled via ``logging.disabled``.
|
||||||
|
|
||||||
|
Only enables logging levels greater than or equal to the requested ``level``.
|
||||||
|
|
||||||
|
Does nothing if the desired ``level`` wasn't disabled.
|
||||||
|
|
||||||
|
:param level:
|
||||||
|
The logger level caplog should capture.
|
||||||
|
All logging is enabled if a non-standard logging level string is supplied.
|
||||||
|
Valid level strings are in :data:`logging._nameToLevel`.
|
||||||
|
:param logger_obj: The logger object to check.
|
||||||
|
|
||||||
|
:return: The original disabled logging level.
|
||||||
|
"""
|
||||||
|
original_disable_level: int = logger_obj.manager.disable # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
if isinstance(level, str):
|
||||||
|
# Try to translate the level string to an int for `logging.disable()`
|
||||||
|
level = logging.getLevelName(level)
|
||||||
|
|
||||||
|
if not isinstance(level, int):
|
||||||
|
# The level provided was not valid, so just un-disable all logging.
|
||||||
|
logging.disable(logging.NOTSET)
|
||||||
|
elif not logger_obj.isEnabledFor(level):
|
||||||
|
# Each level is `10` away from other levels.
|
||||||
|
# https://docs.python.org/3/library/logging.html#logging-levels
|
||||||
|
disable_level = max(level - 10, logging.NOTSET)
|
||||||
|
logging.disable(disable_level)
|
||||||
|
|
||||||
|
return original_disable_level
|
||||||
|
|
||||||
def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> None:
|
def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> None:
|
||||||
"""Set the level of a logger for the duration of a test.
|
"""Set the level of a logger for the duration of a test.
|
||||||
|
|
||||||
|
@ -460,6 +499,8 @@ class LogCaptureFixture:
|
||||||
The levels of the loggers changed by this function will be
|
The levels of the loggers changed by this function will be
|
||||||
restored to their initial values at the end of the test.
|
restored to their initial values at the end of the test.
|
||||||
|
|
||||||
|
Will enable the requested logging level if it was disabled via :meth:`logging.disable`.
|
||||||
|
|
||||||
:param level: The level.
|
:param level: The level.
|
||||||
:param logger: The logger to update. If not given, the root logger.
|
:param logger: The logger to update. If not given, the root logger.
|
||||||
"""
|
"""
|
||||||
|
@ -470,6 +511,9 @@ class LogCaptureFixture:
|
||||||
if self._initial_handler_level is None:
|
if self._initial_handler_level is None:
|
||||||
self._initial_handler_level = self.handler.level
|
self._initial_handler_level = self.handler.level
|
||||||
self.handler.setLevel(level)
|
self.handler.setLevel(level)
|
||||||
|
initial_disabled_logging_level = self._force_enable_logging(level, logger_obj)
|
||||||
|
if self._initial_disabled_logging_level is None:
|
||||||
|
self._initial_disabled_logging_level = initial_disabled_logging_level
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def at_level(
|
def at_level(
|
||||||
|
@ -479,6 +523,8 @@ class LogCaptureFixture:
|
||||||
the end of the 'with' statement the level is restored to its original
|
the end of the 'with' statement the level is restored to its original
|
||||||
value.
|
value.
|
||||||
|
|
||||||
|
Will enable the requested logging level if it was disabled via :meth:`logging.disable`.
|
||||||
|
|
||||||
:param level: The level.
|
:param level: The level.
|
||||||
:param logger: The logger to update. If not given, the root logger.
|
:param logger: The logger to update. If not given, the root logger.
|
||||||
"""
|
"""
|
||||||
|
@ -487,11 +533,13 @@ class LogCaptureFixture:
|
||||||
logger_obj.setLevel(level)
|
logger_obj.setLevel(level)
|
||||||
handler_orig_level = self.handler.level
|
handler_orig_level = self.handler.level
|
||||||
self.handler.setLevel(level)
|
self.handler.setLevel(level)
|
||||||
|
original_disable_level = self._force_enable_logging(level, logger_obj)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
logger_obj.setLevel(orig_level)
|
logger_obj.setLevel(orig_level)
|
||||||
self.handler.setLevel(handler_orig_level)
|
self.handler.setLevel(handler_orig_level)
|
||||||
|
logging.disable(original_disable_level)
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# mypy: disable-error-code="attr-defined"
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -8,6 +9,19 @@ logger = logging.getLogger(__name__)
|
||||||
sublogger = logging.getLogger(__name__ + ".baz")
|
sublogger = logging.getLogger(__name__ + ".baz")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cleanup_disabled_logging():
|
||||||
|
"""Simple fixture that ensures that a test doesn't disable logging.
|
||||||
|
|
||||||
|
This is necessary because ``logging.disable()`` is global, so a test disabling logging
|
||||||
|
and not cleaning up after will break every test that runs after it.
|
||||||
|
|
||||||
|
This behavior was moved to a fixture so that logging will be un-disabled even if the test fails an assertion.
|
||||||
|
"""
|
||||||
|
yield
|
||||||
|
logging.disable(logging.NOTSET)
|
||||||
|
|
||||||
|
|
||||||
def test_fixture_help(pytester: Pytester) -> None:
|
def test_fixture_help(pytester: Pytester) -> None:
|
||||||
result = pytester.runpytest("--fixtures")
|
result = pytester.runpytest("--fixtures")
|
||||||
result.stdout.fnmatch_lines(["*caplog*"])
|
result.stdout.fnmatch_lines(["*caplog*"])
|
||||||
|
@ -28,10 +42,27 @@ def test_change_level(caplog):
|
||||||
assert "CRITICAL" in caplog.text
|
assert "CRITICAL" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_level_logging_disabled(caplog, cleanup_disabled_logging):
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
assert logging.root.manager.disable == logging.CRITICAL
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
logger.info("handler INFO level")
|
||||||
|
logger.warning("handler WARNING level")
|
||||||
|
|
||||||
|
caplog.set_level(logging.CRITICAL, logger=sublogger.name)
|
||||||
|
sublogger.warning("logger SUB_WARNING level")
|
||||||
|
sublogger.critical("logger SUB_CRITICAL level")
|
||||||
|
|
||||||
|
assert "INFO" not in caplog.text
|
||||||
|
assert "WARNING" in caplog.text
|
||||||
|
assert "SUB_WARNING" not in caplog.text
|
||||||
|
assert "SUB_CRITICAL" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_change_level_undo(pytester: Pytester) -> None:
|
def test_change_level_undo(pytester: Pytester) -> None:
|
||||||
"""Ensure that 'set_level' is undone after the end of the test.
|
"""Ensure that 'set_level' is undone after the end of the test.
|
||||||
|
|
||||||
Tests the logging output themselves (affacted both by logger and handler levels).
|
Tests the logging output themselves (affected both by logger and handler levels).
|
||||||
"""
|
"""
|
||||||
pytester.makepyfile(
|
pytester.makepyfile(
|
||||||
"""
|
"""
|
||||||
|
@ -54,6 +85,37 @@ def test_change_level_undo(pytester: Pytester) -> None:
|
||||||
result.stdout.no_fnmatch_line("*log from test2*")
|
result.stdout.no_fnmatch_line("*log from test2*")
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_disabled_level_undo(
|
||||||
|
pytester: Pytester, cleanup_disabled_logging
|
||||||
|
) -> None:
|
||||||
|
"""Ensure that '_force_enable_logging' in 'set_level' is undone after the end of the test.
|
||||||
|
|
||||||
|
Tests the logging output themselves (affected by disabled logging level).
|
||||||
|
"""
|
||||||
|
pytester.makepyfile(
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
def test1(caplog):
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
# using + operator here so fnmatch_lines doesn't match the code in the traceback
|
||||||
|
logging.info('log from ' + 'test1')
|
||||||
|
assert 0
|
||||||
|
|
||||||
|
def test2(caplog):
|
||||||
|
# using + operator here so fnmatch_lines doesn't match the code in the traceback
|
||||||
|
# use logging.warning because we need a level that will show up if logging.disabled
|
||||||
|
# isn't reset to ``CRITICAL`` after test1.
|
||||||
|
logging.warning('log from ' + 'test2')
|
||||||
|
assert 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = pytester.runpytest()
|
||||||
|
result.stdout.fnmatch_lines(["*log from test1*", "*2 failed in *"])
|
||||||
|
result.stdout.no_fnmatch_line("*log from test2*")
|
||||||
|
|
||||||
|
|
||||||
def test_change_level_undos_handler_level(pytester: Pytester) -> None:
|
def test_change_level_undos_handler_level(pytester: Pytester) -> None:
|
||||||
"""Ensure that 'set_level' is undone after the end of the test (handler).
|
"""Ensure that 'set_level' is undone after the end of the test (handler).
|
||||||
|
|
||||||
|
@ -97,6 +159,65 @@ def test_with_statement(caplog):
|
||||||
assert "CRITICAL" in caplog.text
|
assert "CRITICAL" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_statement_logging_disabled(caplog, cleanup_disabled_logging):
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
assert logging.root.manager.disable == logging.CRITICAL
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
logger.debug("handler DEBUG level")
|
||||||
|
logger.info("handler INFO level")
|
||||||
|
logger.warning("handler WARNING level")
|
||||||
|
logger.error("handler ERROR level")
|
||||||
|
logger.critical("handler CRITICAL level")
|
||||||
|
|
||||||
|
assert logging.root.manager.disable == logging.INFO
|
||||||
|
|
||||||
|
with caplog.at_level(logging.CRITICAL, logger=sublogger.name):
|
||||||
|
sublogger.warning("logger SUB_WARNING level")
|
||||||
|
sublogger.critical("logger SUB_CRITICAL level")
|
||||||
|
|
||||||
|
assert "DEBUG" not in caplog.text
|
||||||
|
assert "INFO" not in caplog.text
|
||||||
|
assert "WARNING" in caplog.text
|
||||||
|
assert "ERROR" in caplog.text
|
||||||
|
assert " CRITICAL" in caplog.text
|
||||||
|
assert "SUB_WARNING" not in caplog.text
|
||||||
|
assert "SUB_CRITICAL" in caplog.text
|
||||||
|
assert logging.root.manager.disable == logging.CRITICAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"level_str,expected_disable_level",
|
||||||
|
[
|
||||||
|
("CRITICAL", logging.ERROR),
|
||||||
|
("ERROR", logging.WARNING),
|
||||||
|
("WARNING", logging.INFO),
|
||||||
|
("INFO", logging.DEBUG),
|
||||||
|
("DEBUG", logging.NOTSET),
|
||||||
|
("NOTSET", logging.NOTSET),
|
||||||
|
("NOTVALIDLEVEL", logging.NOTSET),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_force_enable_logging_level_string(
|
||||||
|
caplog, cleanup_disabled_logging, level_str, expected_disable_level
|
||||||
|
):
|
||||||
|
"""Test _force_enable_logging using a level string.
|
||||||
|
|
||||||
|
``expected_disable_level`` is one level below ``level_str`` because the disabled log level
|
||||||
|
always needs to be *at least* one level lower than the level that caplog is trying to capture.
|
||||||
|
"""
|
||||||
|
test_logger = logging.getLogger("test_str_level_force_enable")
|
||||||
|
# Emulate a testing environment where all logging is disabled.
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
# Make sure all logging is disabled.
|
||||||
|
assert not test_logger.isEnabledFor(logging.CRITICAL)
|
||||||
|
# Un-disable logging for `level_str`.
|
||||||
|
caplog._force_enable_logging(level_str, test_logger)
|
||||||
|
# Make sure that the disabled level is now one below the requested logging level.
|
||||||
|
# We don't use `isEnabledFor` here because that also checks the level set by
|
||||||
|
# `logging.setLevel()` which is irrelevant to `logging.disable()`.
|
||||||
|
assert test_logger.manager.disable == expected_disable_level
|
||||||
|
|
||||||
|
|
||||||
def test_log_access(caplog):
|
def test_log_access(caplog):
|
||||||
caplog.set_level(logging.INFO)
|
caplog.set_level(logging.INFO)
|
||||||
logger.info("boo %s", "arg")
|
logger.info("boo %s", "arg")
|
||||||
|
|
Loading…
Reference in New Issue