Skip to content

Commit e0afbee

Browse files
author
Tobias Deiminger
committed
Automatically attach to non-propagating loggers
Currently, pytest attaches its log handlers only to the root logger, and relies on log propagation to catch logs from loggers deeper in the hierarchy. In consequence we miss logs if one of those child loggers has logger.propagate set to False by user code. This affects caplog, report logging and live logging. This commit showcases a possible fix, where we attach not only to the root logger, but also to non-propagating loggers as they occur. To do so we patch the standard libraries logging module with our own descriptor that intercepts assignments to logger.propagate. This enables us to inject and remove our pytest handlers right on time, even during executing test calls, with neglible performance impact. Attempt to fix pytest-dev#3697.
1 parent 34765f6 commit e0afbee

File tree

2 files changed

+118
-15
lines changed

2 files changed

+118
-15
lines changed

src/_pytest/logging.py

+89-15
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
from io import StringIO
99
from pathlib import Path
1010
from typing import AbstractSet
11+
from typing import cast
1112
from typing import Dict
1213
from typing import Generator
1314
from typing import List
1415
from typing import Mapping
1516
from typing import Optional
17+
from typing import Set
1618
from typing import Tuple
1719
from typing import TYPE_CHECKING
1820
from typing import TypeVar
@@ -47,12 +49,88 @@
4749
_ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m")
4850
caplog_handler_key = StashKey["LogCaptureHandler"]()
4951
caplog_records_key = StashKey[Dict[str, List[logging.LogRecord]]]()
52+
_HandlerType = TypeVar("_HandlerType", bound=logging.Handler)
5053

5154

5255
def _remove_ansi_escape_sequences(text: str) -> str:
5356
return _ANSI_ESCAPE_SEQ.sub("", text)
5457

5558

59+
class CatchedLoggers:
60+
__slots__ = ("handler", "level", "loggers")
61+
62+
def __init__(
63+
self,
64+
initial_set: Set[logging.Logger],
65+
handler: _HandlerType,
66+
level: Optional[int] = None,
67+
):
68+
self.handler = handler
69+
self.level = level
70+
self.loggers: Dict[logging.Logger, Optional[int]] = {}
71+
if self.level is not None:
72+
self.handler.setLevel(self.level)
73+
for logger in initial_set:
74+
self.catch_logger(logger)
75+
76+
def catch_logger(self, logger: logging.Logger):
77+
if logger not in self.loggers:
78+
logger.addHandler(self.handler)
79+
if self.level is not None:
80+
logger.setLevel(min(logger.level, self.level))
81+
self.loggers[logger] = logger.level # remember original log level
82+
else:
83+
self.loggers[logger] = None
84+
85+
def release_logger(self, logger: logging.Logger):
86+
if logger in self.loggers:
87+
logger.removeHandler(self.handler)
88+
orig_level = self.loggers.get(logger)
89+
if orig_level is not None:
90+
logger.setLevel(orig_level)
91+
del self.loggers[logger]
92+
93+
def release_all_loggers(self):
94+
for logger in list(self.loggers.keys()):
95+
self.release_logger(logger)
96+
97+
98+
class LoggerPropagateInterceptor:
99+
"""Descriptor to be patched into standard libs logging module.
100+
101+
It intercepts propagate = False assignments from user code.
102+
103+
While catching_logs is inactive, it tracks non-propagating loggers without side effects.
104+
While catching_logs is active, it also adds and removes pytest handlers instantly.
105+
"""
106+
107+
non_propagating_loggers: Set[logging.Logger] = set()
108+
catchers: Set[CatchedLoggers] = set()
109+
110+
def __init__(self):
111+
for item in logging.getLogger().manager.loggerDict:
112+
if isinstance(item, logging.Logger) and not item.propagate:
113+
self.non_propagating_loggers.add(item)
114+
115+
def __set__(self, logger: logging.Logger, propagate: bool):
116+
if propagate is False:
117+
if self.catchers:
118+
for catcher in self.catchers:
119+
catcher.catch_logger(logger)
120+
self.non_propagating_loggers.add(logger)
121+
elif propagate is True:
122+
if self.catchers:
123+
for catcher in self.catchers:
124+
catcher.release_logger(logger)
125+
if logger in self.non_propagating_loggers:
126+
self.non_propagating_loggers.remove(logger)
127+
logger.__dict__["propagate"] = propagate
128+
129+
130+
# From now, we intercept each assignment to logger.propagate
131+
logging.Logger.propagate = cast(bool, LoggerPropagateInterceptor())
132+
133+
56134
class ColoredLevelFormatter(logging.Formatter):
57135
"""A logging formatter which colorizes the %(levelname)..s part of the
58136
log format passed to __init__."""
@@ -299,34 +377,30 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs):
299377
)
300378

301379

302-
_HandlerType = TypeVar("_HandlerType", bound=logging.Handler)
303-
304-
305380
# Not using @contextmanager for performance reasons.
306381
class catching_logs:
307382
"""Context manager that prepares the whole logging machinery properly."""
308383

309-
__slots__ = ("handler", "level", "orig_level")
384+
__slots__ = ("handler", "level", "catcher")
310385

311386
def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None:
312387
self.handler = handler
313388
self.level = level
389+
self.catcher: Optional[CatchedLoggers] = None
314390

315391
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))
392+
self.catcher = CatchedLoggers(
393+
LoggerPropagateInterceptor.non_propagating_loggers | {logging.getLogger()},
394+
self.handler,
395+
self.level,
396+
)
397+
LoggerPropagateInterceptor.catchers.add(self.catcher)
323398
return self.handler
324399

325400
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)
401+
assert self.catcher
402+
LoggerPropagateInterceptor.catchers.remove(self.catcher)
403+
self.catcher.release_all_loggers()
330404

331405

332406
class LogCaptureHandler(logging_StreamHandler):

testing/logging/test_reporting.py

+29
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,35 @@ def test_second():
10741074
assert "message from test 2" in content
10751075

10761076

1077+
def test_log_propagation_false(pytester: Pytester) -> None:
1078+
pytester.makepyfile(
1079+
"""
1080+
import pytest
1081+
import logging
1082+
1083+
logging.getLogger('foo').propagate = False
1084+
1085+
def test_log_file(request):
1086+
plugin = request.config.pluginmanager.getplugin('logging-plugin')
1087+
logging.getLogger().warning("log goes to root logger")
1088+
logging.getLogger('foo').warning("log goes to initially non-propagating logger")
1089+
logging.getLogger('foo.bar').propagate = False
1090+
logging.getLogger('foo.bar').warning("log goes to propagation-disabled-in-test logger")
1091+
assert False, "intentionally fail to trigger report logging output"
1092+
"""
1093+
)
1094+
1095+
result = pytester.runpytest("-s")
1096+
result.stdout.re_match_lines(
1097+
[
1098+
"-+ Captured log call -+",
1099+
r"WARNING\s+root:test_log_propagation_false.py:8\s+log goes to root logger",
1100+
r"WARNING\s+foo:test_log_propagation_false.py:9\s+log goes to initially non-propagating logger",
1101+
r"WARNING\s+foo.bar:test_log_propagation_false.py:11\s+log goes to propagation-disabled-in-test logger",
1102+
]
1103+
)
1104+
1105+
10771106
def test_colored_captured_log(pytester: Pytester) -> None:
10781107
"""Test that the level names of captured log messages of a failing test
10791108
are colored."""

0 commit comments

Comments
 (0)