From 8f0f6618aeea36bf19d273c0a0e2f620cdcb7c05 Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 04:09:55 +0000 Subject: [PATCH 1/2] Fix nested caplog.filtering() removing filter early (#14189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the same filter was used in nested `caplog.filtering()` context managers, the inner context's exit would call `removeFilter()` which removes the filter entirely — even though the outer context still needs it. This happened because `removeFilter()` works by identity/value. Fix by checking if the filter is already present before adding. If it was already attached (from an outer scope), skip both the add and the remove, leaving the outer scope's lifecycle in control. Co-Authored-By: Claude Opus 4.6 --- changelog/14189.bugfix.rst | 1 + src/_pytest/logging.py | 11 +++++++++-- testing/logging/test_fixture.py | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 changelog/14189.bugfix.rst diff --git a/changelog/14189.bugfix.rst b/changelog/14189.bugfix.rst new file mode 100644 index 00000000000..101c3c21a3e --- /dev/null +++ b/changelog/14189.bugfix.rst @@ -0,0 +1 @@ +Fixed nested :meth:`caplog.filtering() ` calls with the same filter removing the filter too early when the inner context exits. diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 6f34c1b93fd..ce52ebb303d 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -581,15 +581,22 @@ def filtering(self, filter_: logging.Filter) -> Generator[None]: :meth:`handler` for the 'with' statement block, and removes that filter at the end of the block. + If the filter is already attached to the handler (e.g. from an outer + nested ``filtering()`` call), it is not added again, and will not be + removed when the inner block exits. + :param filter_: A custom :class:`logging.Filter` object. .. versionadded:: 7.5 """ - self.handler.addFilter(filter_) + already_present = filter_ in self.handler.filters + if not already_present: + self.handler.addFilter(filter_) try: yield finally: - self.handler.removeFilter(filter_) + if not already_present: + self.handler.removeFilter(filter_) @fixture diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 5f94cb8508a..8202665dab1 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -206,6 +206,25 @@ def filter(self, record: logging.LogRecord) -> bool: assert unfiltered_tuple == ("test_fixture", 20, "handler call") +def test_nested_filtering_same_filter(caplog: pytest.LogCaptureFixture) -> None: + """Nested ``caplog.filtering()`` with the same filter should not remove + the filter when the inner context exits (#14189).""" + + def no_capture(record: logging.LogRecord) -> bool: + return False + + with caplog.at_level(logging.INFO): + with caplog.filtering(no_capture): + logger.info("outer before inner") + with caplog.filtering(no_capture): + logger.info("inside inner") + logger.info("outer after inner") + logger.info("outside both") + + assert len(caplog.records) == 1 + assert caplog.records[0].message == "outside both" + + @pytest.mark.parametrize( "level_str,expected_disable_level", [ From f604926d27588e4e736f7723b2dbaf6ca5c70eba Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 05:27:04 +0000 Subject: [PATCH 2/2] Fix mypy: use logging.Filter subclass instead of callable --- testing/logging/test_fixture.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 8202665dab1..133b656b0da 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -210,13 +210,15 @@ def test_nested_filtering_same_filter(caplog: pytest.LogCaptureFixture) -> None: """Nested ``caplog.filtering()`` with the same filter should not remove the filter when the inner context exits (#14189).""" - def no_capture(record: logging.LogRecord) -> bool: - return False + class NoCaptureFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return False + filter_instance = NoCaptureFilter() with caplog.at_level(logging.INFO): - with caplog.filtering(no_capture): + with caplog.filtering(filter_instance): logger.info("outer before inner") - with caplog.filtering(no_capture): + with caplog.filtering(filter_instance): logger.info("inside inner") logger.info("outer after inner") logger.info("outside both")