Skip to content

Commit 13a1f37

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 13a1f37

File tree

2 files changed

+129
-15
lines changed

2 files changed

+129
-15
lines changed

src/_pytest/logging.py

Lines changed: 100 additions & 15 deletions
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,99 @@
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+
"""Add pytest handler to loggers on demand.
61+
62+
Usually it would be enough to simply add pytest handlers to the root logger at fixed stages and rely
63+
on log propagation to capture logs from child loggers. However, if a user turns off propagation
64+
for a child logger, we would miss their logs. The chin-ups here and in LoggerPropagateInterceptor
65+
are mainly to capture child loggers with propagate = False, by instantly adding pytest handlers to
66+
them.
67+
"""
68+
69+
__slots__ = ("handler", "level", "loggers")
70+
71+
def __init__(
72+
self,
73+
initial_set: Set[logging.Logger],
74+
handler: _HandlerType,
75+
level: Optional[int] = None,
76+
):
77+
self.handler = handler
78+
self.level = level
79+
self.loggers: Dict[logging.Logger, Optional[int]] = {}
80+
if self.level is not None:
81+
self.handler.setLevel(self.level)
82+
for logger in initial_set:
83+
self.catch_logger(logger)
84+
85+
def catch_logger(self, logger: logging.Logger):
86+
"""Add pytest handler and adjust level of logger. Remember state to restore."""
87+
if logger not in self.loggers:
88+
logger.addHandler(self.handler)
89+
if self.level is not None:
90+
self.loggers[logger] = logger.level
91+
logger.setLevel(min(logger.level, self.level))
92+
else:
93+
self.loggers[logger] = None
94+
95+
def release_logger(self, logger: logging.Logger):
96+
"""Restore log level and remove pytest handler."""
97+
if logger in self.loggers:
98+
logger.removeHandler(self.handler)
99+
orig_level = self.loggers.get(logger)
100+
if orig_level is not None:
101+
logger.setLevel(orig_level)
102+
del self.loggers[logger]
103+
104+
def release_all_loggers(self):
105+
for logger in list(self.loggers.keys()):
106+
self.release_logger(logger)
107+
108+
109+
class LoggerPropagateInterceptor:
110+
"""Descriptor to be patched into standard libs logging module.
111+
112+
It intercepts propagate = False assignments from user code.
113+
114+
While catching_logs is inactive, it tracks non-propagating loggers without side effects.
115+
While catching_logs is active, it also adds and removes pytest handlers instantly.
116+
"""
117+
118+
non_propagating_loggers: Set[logging.Logger] = set()
119+
catchers: Set[CatchedLoggers] = set()
120+
121+
def __init__(self):
122+
for item in logging.getLogger().manager.loggerDict:
123+
if isinstance(item, logging.Logger) and not item.propagate:
124+
self.non_propagating_loggers.add(item)
125+
126+
def __set__(self, logger: logging.Logger, propagate: bool):
127+
if propagate is False:
128+
if self.catchers:
129+
for catcher in self.catchers:
130+
catcher.catch_logger(logger)
131+
self.non_propagating_loggers.add(logger)
132+
elif propagate is True:
133+
if self.catchers:
134+
for catcher in self.catchers:
135+
catcher.release_logger(logger)
136+
if logger in self.non_propagating_loggers:
137+
self.non_propagating_loggers.remove(logger)
138+
logger.__dict__["propagate"] = propagate
139+
140+
141+
# From now, we intercept each assignment to logger.propagate
142+
logging.Logger.propagate = cast(bool, LoggerPropagateInterceptor())
143+
144+
56145
class ColoredLevelFormatter(logging.Formatter):
57146
"""A logging formatter which colorizes the %(levelname)..s part of the
58147
log format passed to __init__."""
@@ -299,34 +388,30 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs):
299388
)
300389

301390

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

309-
__slots__ = ("handler", "level", "orig_level")
395+
__slots__ = ("handler", "level", "catcher")
310396

311397
def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None:
312398
self.handler = handler
313399
self.level = level
400+
self.catcher: Optional[CatchedLoggers] = None
314401

315402
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))
403+
self.catcher = CatchedLoggers(
404+
LoggerPropagateInterceptor.non_propagating_loggers | {logging.getLogger()},
405+
self.handler,
406+
self.level,
407+
)
408+
LoggerPropagateInterceptor.catchers.add(self.catcher)
323409
return self.handler
324410

325411
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)
412+
assert self.catcher
413+
LoggerPropagateInterceptor.catchers.remove(self.catcher)
414+
self.catcher.release_all_loggers()
330415

331416

332417
class LogCaptureHandler(logging_StreamHandler):

testing/logging/test_reporting.py

Lines changed: 29 additions & 0 deletions
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)