From 4b9e03a813330bee30c50b184f69c4f29be3274b Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 16:25:25 +0000 Subject: [PATCH 1/8] Fix nested caplog.filtering early removal Closes #14189 by checking if the filter is already present before adding or removing it. --- src/_pytest/logging.py | 7 +++++-- testing/logging/test_fixture.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 6f34c1b93fd..48960bcf4bc 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -585,11 +585,14 @@ def filtering(self, filter_: logging.Filter) -> Generator[None]: .. 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..8f50f022b7c 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -206,6 +206,19 @@ def filter(self, record: logging.LogRecord) -> bool: assert unfiltered_tuple == ("test_fixture", 20, "handler call") +def test_with_statement_nested_filtering(caplog: pytest.LogCaptureFixture) -> None: + def no_capture_filter(log_record: logging.LogRecord) -> bool: + return False + + with caplog.filtering(no_capture_filter): # type: ignore[arg-type] + logger.warning("Will not be captured") + with caplog.filtering(no_capture_filter): # type: ignore[arg-type] + logger.warning("Will also not be captured") + logger.warning("Should not be captured either") + + assert caplog.records == [] + + @pytest.mark.parametrize( "level_str,expected_disable_level", [ From 9c69d47309f82d13f597a00fcfd0ffedee427a34 Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 16:34:39 +0000 Subject: [PATCH 2/8] Add changelog entry for #14189 --- changelog/14189.bugfix.rst | 1 + 1 file changed, 1 insertion(+) 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..cdb0680e35a --- /dev/null +++ b/changelog/14189.bugfix.rst @@ -0,0 +1 @@ +Nested usage of :meth:`caplog.filtering ` no longer removes filters early if they were already present. From d6038c27489f876934bc88c78111fd47afebed2a Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 16:44:25 +0000 Subject: [PATCH 3/8] Add regression test for already-present filters in caplog.filtering --- testing/logging/test_fixture.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 8f50f022b7c..4c0306ee54c 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -219,6 +219,24 @@ def no_capture_filter(log_record: logging.LogRecord) -> bool: assert caplog.records == [] +def test_with_statement_filtering_already_present( + caplog: pytest.LogCaptureFixture, +) -> None: + def no_capture_filter(log_record: logging.LogRecord) -> bool: + return False + + caplog.handler.addFilter(no_capture_filter) # type: ignore[arg-type] + try: + with caplog.filtering(no_capture_filter): # type: ignore[arg-type] + logger.warning("Should not be captured") + + # After context manager, filter should STILL be present because it was already there + logger.warning("Should still not be captured") + assert caplog.records == [] + finally: + caplog.handler.removeFilter(no_capture_filter) # type: ignore[arg-type] + + @pytest.mark.parametrize( "level_str,expected_disable_level", [ From c9d88cdc21630c45c08dda20666e03f2dc4111dd Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 16:45:05 +0000 Subject: [PATCH 4/8] Format pytest logging changes --- src/_pytest/logging.py | 35 ++++++------------ testing/logging/test_fixture.py | 65 +++++++++++---------------------- 2 files changed, 33 insertions(+), 67 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 48960bcf4bc..7dbe1a23d4d 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -3,45 +3,32 @@ from __future__ import annotations -from collections.abc import Generator -from collections.abc import Mapping -from collections.abc import Set as AbstractSet -from contextlib import contextmanager -from contextlib import nullcontext -from datetime import datetime -from datetime import timedelta -from datetime import timezone import io -from io import StringIO import logging -from logging import LogRecord import os -from pathlib import Path import re +from collections.abc import Generator, Mapping +from collections.abc import Set as AbstractSet +from contextlib import contextmanager, nullcontext +from datetime import datetime, timedelta, timezone +from io import StringIO +from logging import LogRecord +from pathlib import Path from types import TracebackType -from typing import final -from typing import Generic -from typing import Literal -from typing import TYPE_CHECKING -from typing import TypeVar +from typing import TYPE_CHECKING, Generic, Literal, TypeVar, final from _pytest import nodes from _pytest._io import TerminalWriter from _pytest.capture import CaptureManager -from _pytest.config import _strtobool -from _pytest.config import Config -from _pytest.config import create_terminal_writer -from _pytest.config import hookimpl -from _pytest.config import UsageError +from _pytest.config import (Config, UsageError, _strtobool, + create_terminal_writer, hookimpl) from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest -from _pytest.fixtures import fixture -from _pytest.fixtures import FixtureRequest +from _pytest.fixtures import FixtureRequest, fixture from _pytest.main import Session from _pytest.stash import StashKey from _pytest.terminal import TerminalReporter - if TYPE_CHECKING: logging_StreamHandler = logging.StreamHandler[StringIO] else: diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 4c0306ee54c..54ced3b8071 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -2,13 +2,12 @@ # mypy: disallow-untyped-defs from __future__ import annotations -from collections.abc import Iterator import logging +from collections.abc import Iterator +import pytest from _pytest.logging import caplog_records_key from _pytest.pytester import Pytester -import pytest - logger = logging.getLogger(__name__) sublogger = logging.getLogger(__name__ + ".baz") @@ -69,8 +68,7 @@ def test_change_level_undo(pytester: Pytester) -> None: Tests the logging output themselves (affected both by logger and handler levels). """ - pytester.makepyfile( - """ + pytester.makepyfile(""" import logging def test1(caplog): @@ -83,8 +81,7 @@ def test2(caplog): # using + operator here so fnmatch_lines doesn't match the code in the traceback logging.info('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*") @@ -95,8 +92,7 @@ def test_change_disabled_level_undo(pytester: Pytester) -> None: Tests the logging output themselves (affected by disabled logging level). """ - pytester.makepyfile( - """ + pytester.makepyfile(""" import logging def test1(caplog): @@ -112,8 +108,7 @@ def test2(caplog): # 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*") @@ -124,8 +119,7 @@ def test_change_level_undoes_handler_level(pytester: Pytester) -> None: Issue #7569. Tests the handler level specifically. """ - pytester.makepyfile( - """ + pytester.makepyfile(""" import logging def test1(caplog): @@ -141,8 +135,7 @@ def test3(caplog): assert caplog.handler.level == 0 caplog.set_level(43) assert caplog.handler.level == 43 - """ - ) + """) result = pytester.runpytest() result.assert_outcomes(passed=3) @@ -377,8 +370,7 @@ def test_clear_for_call_stage( def test_ini_controls_global_log_level(pytester: Pytester) -> None: - pytester.makepyfile( - """ + pytester.makepyfile(""" import pytest import logging def test_log_level_override(request, caplog): @@ -389,14 +381,11 @@ def test_log_level_override(request, caplog): logger.error("ERROR message will be shown") assert 'WARNING' not in caplog.text assert 'ERROR' in caplog.text - """ - ) - pytester.makeini( - """ + """) + pytester.makeini(""" [pytest] log_level=ERROR - """ - ) + """) result = pytester.runpytest() # make sure that we get a '0' exit code for the testsuite @@ -404,8 +393,7 @@ def test_log_level_override(request, caplog): def test_can_override_global_log_level(pytester: Pytester) -> None: - pytester.makepyfile( - """ + pytester.makepyfile(""" import pytest import logging def test_log_level_override(request, caplog): @@ -429,22 +417,18 @@ def test_log_level_override(request, caplog): logger.info("INFO message will be shown") assert "message won't be shown" not in caplog.text - """ - ) - pytester.makeini( - """ + """) + pytester.makeini(""" [pytest] log_level=WARNING - """ - ) + """) result = pytester.runpytest() assert result.ret == 0 def test_captures_despite_exception(pytester: Pytester) -> None: - pytester.makepyfile( - """ + pytester.makepyfile(""" import pytest import logging def test_log_level_override(request, caplog): @@ -457,14 +441,11 @@ def test_log_level_override(request, caplog): with caplog.at_level(logging.DEBUG, logger.name): logger.debug("DEBUG message " + "won't be shown") raise Exception() - """ - ) - pytester.makeini( - """ + """) + pytester.makeini(""" [pytest] log_level=WARNING - """ - ) + """) result = pytester.runpytest() result.stdout.fnmatch_lines(["*ERROR message will be shown*"]) @@ -480,8 +461,7 @@ def test_log_report_captures_according_to_config_option_upon_failure( (2) The `DEBUG` message does NOT appear in the `Captured log call` report. (3) The stdout, `INFO`, and `WARNING` messages DO appear in the test reports due to `--log-level=INFO`. """ - pytester.makepyfile( - """ + pytester.makepyfile(""" import pytest import logging @@ -502,8 +482,7 @@ def test_that_fails(request, caplog): raise Exception('caplog failed to ' + 'capture DEBUG') assert False - """ - ) + """) result = pytester.runpytest("--log-level=INFO") result.stdout.no_fnmatch_line("*Exception: caplog failed to capture DEBUG*") From 103c50268163dc049f2c34ffc53ad2e1034d3dea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:45:26 +0000 Subject: [PATCH 5/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/logging.py | 35 ++++++++++++++++++++++----------- testing/logging/test_fixture.py | 5 +++-- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 7dbe1a23d4d..48960bcf4bc 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -3,32 +3,45 @@ from __future__ import annotations -import io -import logging -import os -import re -from collections.abc import Generator, Mapping +from collections.abc import Generator +from collections.abc import Mapping from collections.abc import Set as AbstractSet -from contextlib import contextmanager, nullcontext -from datetime import datetime, timedelta, timezone +from contextlib import contextmanager +from contextlib import nullcontext +from datetime import datetime +from datetime import timedelta +from datetime import timezone +import io from io import StringIO +import logging from logging import LogRecord +import os from pathlib import Path +import re from types import TracebackType -from typing import TYPE_CHECKING, Generic, Literal, TypeVar, final +from typing import final +from typing import Generic +from typing import Literal +from typing import TYPE_CHECKING +from typing import TypeVar from _pytest import nodes from _pytest._io import TerminalWriter from _pytest.capture import CaptureManager -from _pytest.config import (Config, UsageError, _strtobool, - create_terminal_writer, hookimpl) +from _pytest.config import _strtobool +from _pytest.config import Config +from _pytest.config import create_terminal_writer +from _pytest.config import hookimpl +from _pytest.config import UsageError from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest -from _pytest.fixtures import FixtureRequest, fixture +from _pytest.fixtures import fixture +from _pytest.fixtures import FixtureRequest from _pytest.main import Session from _pytest.stash import StashKey from _pytest.terminal import TerminalReporter + if TYPE_CHECKING: logging_StreamHandler = logging.StreamHandler[StringIO] else: diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 54ced3b8071..118a86d54e6 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -2,12 +2,13 @@ # mypy: disallow-untyped-defs from __future__ import annotations -import logging from collections.abc import Iterator +import logging -import pytest from _pytest.logging import caplog_records_key from _pytest.pytester import Pytester +import pytest + logger = logging.getLogger(__name__) sublogger = logging.getLogger(__name__ + ".baz") From d3af9b86495c04f6dfc8822e6b31283ac212dc1f Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 18:12:49 +0000 Subject: [PATCH 6/8] Remove unused type: ignore comments to fix mypy pre-commit --- testing/logging/test_fixture.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 118a86d54e6..53502452fb5 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -204,9 +204,9 @@ def test_with_statement_nested_filtering(caplog: pytest.LogCaptureFixture) -> No def no_capture_filter(log_record: logging.LogRecord) -> bool: return False - with caplog.filtering(no_capture_filter): # type: ignore[arg-type] + with caplog.filtering(no_capture_filter): logger.warning("Will not be captured") - with caplog.filtering(no_capture_filter): # type: ignore[arg-type] + with caplog.filtering(no_capture_filter): logger.warning("Will also not be captured") logger.warning("Should not be captured either") @@ -219,16 +219,16 @@ def test_with_statement_filtering_already_present( def no_capture_filter(log_record: logging.LogRecord) -> bool: return False - caplog.handler.addFilter(no_capture_filter) # type: ignore[arg-type] + caplog.handler.addFilter(no_capture_filter) try: - with caplog.filtering(no_capture_filter): # type: ignore[arg-type] + with caplog.filtering(no_capture_filter): logger.warning("Should not be captured") # After context manager, filter should STILL be present because it was already there logger.warning("Should still not be captured") assert caplog.records == [] finally: - caplog.handler.removeFilter(no_capture_filter) # type: ignore[arg-type] + caplog.handler.removeFilter(no_capture_filter) @pytest.mark.parametrize( From d66f26f65191254f029ad69e68abd0ade07ab719 Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 18:32:32 +0000 Subject: [PATCH 7/8] Use logging.Filter in caplog tests --- testing/logging/test_fixture.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 53502452fb5..eccb21088d8 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -201,8 +201,11 @@ def filter(self, record: logging.LogRecord) -> bool: def test_with_statement_nested_filtering(caplog: pytest.LogCaptureFixture) -> None: - def no_capture_filter(log_record: logging.LogRecord) -> bool: - return False + class NoCaptureFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return False + + no_capture_filter = NoCaptureFilter() with caplog.filtering(no_capture_filter): logger.warning("Will not be captured") @@ -216,8 +219,11 @@ def no_capture_filter(log_record: logging.LogRecord) -> bool: def test_with_statement_filtering_already_present( caplog: pytest.LogCaptureFixture, ) -> None: - def no_capture_filter(log_record: logging.LogRecord) -> bool: - return False + class NoCaptureFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return False + + no_capture_filter = NoCaptureFilter() caplog.handler.addFilter(no_capture_filter) try: From 5bdc8f1bce3f33ebaf1b388584b05653327b1985 Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 18:39:09 +0000 Subject: [PATCH 8/8] Cast callable filters for caplog typing --- testing/logging/test_fixture.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index eccb21088d8..630c910c04b 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -4,6 +4,7 @@ from collections.abc import Iterator import logging +from typing import cast from _pytest.logging import caplog_records_key from _pytest.pytester import Pytester @@ -201,15 +202,12 @@ def filter(self, record: logging.LogRecord) -> bool: def test_with_statement_nested_filtering(caplog: pytest.LogCaptureFixture) -> None: - class NoCaptureFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - return False - - no_capture_filter = NoCaptureFilter() + def no_capture_filter(log_record: logging.LogRecord) -> bool: + return False - with caplog.filtering(no_capture_filter): + with caplog.filtering(cast(logging.Filter, no_capture_filter)): logger.warning("Will not be captured") - with caplog.filtering(no_capture_filter): + with caplog.filtering(cast(logging.Filter, no_capture_filter)): logger.warning("Will also not be captured") logger.warning("Should not be captured either") @@ -219,15 +217,12 @@ def filter(self, record: logging.LogRecord) -> bool: def test_with_statement_filtering_already_present( caplog: pytest.LogCaptureFixture, ) -> None: - class NoCaptureFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - return False - - no_capture_filter = NoCaptureFilter() + def no_capture_filter(log_record: logging.LogRecord) -> bool: + return False caplog.handler.addFilter(no_capture_filter) try: - with caplog.filtering(no_capture_filter): + with caplog.filtering(cast(logging.Filter, no_capture_filter)): logger.warning("Should not be captured") # After context manager, filter should STILL be present because it was already there