Repository navigation
logging: capture a record once when a non-propagating logger starts propagating (#15064) - #15138
RonnyPfannschmidt wants to merge 3 commits into
Conversation
…ropagating (pytest-dev#15064) catching_logs attached the capture handler itself to every logger that was non-propagating at enter. Once such a logger propagates again, the record reaches the handler on the logger and on root. Attach a stand-in instead, which decides at emit time, from the current propagate values up the chain, whether the walk still ends below root at a logger nothing else delivers on. Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Ancestors of loggers that are non-propagating at enter now get a stand-in as well, so a record stopping at an ancestor that becomes non-propagating mid-test (e.g. from a sibling) is still captured. Loggers sharing one handlers list cannot tell their records apart, so such a list gets the pytest handler itself, as before pytest-dev#15064; with a stand-in per logger every record was captured twice. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0183Jee4Yw5vPqSEfExPfgL9
Registering every short-lived stand-in in logging's global handler list dominated the cost of entering catching_logs once ancestors get one too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0183Jee4Yw5vPqSEfExPfgL9
328519c to
391300f
Compare
There was a problem hiding this comment.
While this works, it seems to me like a complex solution to solve a somewhat minor problem, my gut feeling was to dedup the messages instead.
I asked Claude if it could come up with a simpler solution, see that below. Feel free to consider it before merging.
I think the stand-in approach adds more machinery than #15064 needs. It brings a new handler class, ancestor stand-ins, a fallback for shared handlers lists, and a way to skip Handler.__init__. It also has a few edge cases of its own:
- Records captured twice: when propagating ancestors share a
handlerslist, the shared-list fallback attaches the real handler to a logger that still propagates. For example, withb.handlers = a.handlersanda.c/b.cnon-propagating,a.warning("x")ends up captured twice. Onmainit is captured once. - Extra handlers on ancestors: every ancestor of a non-propagating logger now has a stand-in in
logger.handlers. That breaks the commonif not logger.handlers: configure()guard, and tests that assert onhandlers. - Records lost: if something like
dictConfigclears an ancestor's handlers mid-test, its stand-in is removed. A child's stand-in still defers to that ancestor, so the child's records are dropped.
Proposal: keep the current behaviour (the handler attached to root and to every non-propagating logger) and make the handler ignore a record it has already seen while catching_logs is active:
class _HandleOnceFilter(logging.Filter):
"""Lets each record through only once.
The handler is attached to root and to the loggers which do not propagate
when capture starts; if one of those starts propagating, its records reach
the handler twice (#15064).
"""
def __init__(self) -> None:
super().__init__()
self.seen: weakref.WeakSet[LogRecord] = weakref.WeakSet()
def filter(self, record: LogRecord) -> bool:
if record in self.seen:
return False
self.seen.add(record)
return Truecatching_logs.__enter__ adds this filter to the handler only when it attached to at least one non-propagating logger, and __exit__ removes it. Because logging passes the same LogRecord object to every handler along the propagation path, removing duplicates by identity works no matter how the loggers are arranged. The WeakSet means we never keep records alive. In total it's about 28 lines added to logging.py compared with main.
I tried it locally:
- Your tests for #15064 pass with it: the plain and nested starts-propagating cases, subtests, and live log/log file. I also added one for propagating ancestors that share a
handlerslist. All the logging tests pass (98). - Two tests from the PR would go:
test_captures_sibling_when_ancestor_becomes_non_propagatingandtest_raising_handler_stops_capture_as_for_propagating_loggers. They check behaviour only the stand-in design provides. Catching loggers that become non-propagating mid-test is a separate limitation thatmainalready documents as "not worth the trouble". With the direct handler, a record is captured at the first logger, before a parent handler can raise, which is the same as onmain.
Trade-offs I'm aware of: the filter shows up in handler.filters while capture is active, and if user code passes the same record object through the handler twice on purpose, the second pass is dropped. Both seem acceptable to me.
I pushed the local changes to https://github.com/nicoddemus/pytest/commits/RonnyPfannschmidt/fix-15064-propagation-stand-in (43faeb1) if you want to take it from there. 👍
| time, ends below root at a logger that does not otherwise deliver to the | ||
| pytest handler; otherwise root (or another stand-in further up) delivers it. | ||
| It has no level, filters or formatter of its own: those of the pytest | ||
| handler apply. |
There was a problem hiding this comment.
I think a paragraph stating what this class solves (perhaps along with the issue number) would be useful to have.
| self.handler = handler | ||
| self.delivered_at = delivered_at | ||
|
|
||
| def handle(self, record: LogRecord) -> bool: |
Requested by Ronny · project thread
🤖 Written by Claude Opus 5.5 via Claude Code for the pytest maintainers; I prompted it, it did the work, I read it.
Fixes #15064.
Since #14375,
catching_logsattaches the capture handler to root and also directly to every logger that is non-propagating at enter. Once such a logger propagates again, a record reaches the handler on the logger and again on root.Change
propagateis true. It forwards to the capture handler only if that walk ends below root at a logger that nothing else delivers on. Otherwise root, or a stand-in further up, delivers the record. Because ancestors have stand-ins, a record that stops at an ancestor which became non-propagating mid-test (for example from a sibling logger) is still captured.handlerslist cannot tell their records apart, so such a list gets the capture handler itself, as on main. A stand-in per logger there would capture every record twice.handle(), notemit(), and takes no lock (lock = None, aslogging.NullHandlerdoes). It therefore cannot deadlock against the target handler's lock. It also skipsHandler.__init__, so it is not registered in logging's global handler list.Tests
12 new tests on public behaviour (
caplog, report sections, live log), acrosstesting/logging/test_fixture.pyandtest_reporting.py:handlerslist.Cost
CPython 3.13, medians of 3 runs (emit in ns per record, enter+exit in µs), end to end median of 5:
At realistic depths the difference is within noise. The visible cost is one stand-in visit, about 190 ns, per ancestor a propagating record passes on its way to root, so it grows with the depth of the logger tree below a non-propagating logger. An experiment that kept stand-ins on every logger, to also cover the remaining gap below, cost 1.4–1.5x on every emitted record, so this PR does not do that.
Behaviour notes and open questions
handlerslists keep main's behaviour, including its duplicate when one of those loggers starts propagating.logger.handlersnow shows the stand-in, not the capture handler. Socaplog.handler in logger.handlerschanges from True to False for non-propagating loggers. Filters or a formatter added to the stand-in are ignored. Should it honour filters too?LoggingPlugin.The first commit was verified on CPython 3.14 and 3.11, the follow-ups on 3.13. At the head commit the full suite passes on 3.14, and so does
pre-commit run -a.