From 4b9e03a813330bee30c50b184f69c4f29be3274b Mon Sep 17 00:00:00 2001 From: Oaksprout Date: Fri, 13 Mar 2026 16:25:25 +0000 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] [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 06/11] 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 07/11] 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 08/11] 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 From d5e9fb214dc354d67699018e553ee40d82a14ff7 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 26 Aug 2026 10:03:59 +0200 Subject: [PATCH 09/11] Revert unrelated reformatting of test_fixture.py The makepyfile/makeini docstring reflow is not this repo's style: nothing in pyproject.toml asks for it and ruff format accepts the original layout. It doubled the diff and would conflict on backport. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Code --- testing/logging/test_fixture.py | 60 ++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 630c910c04b..2972f32f890 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -70,7 +70,8 @@ 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,7 +84,8 @@ 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*") @@ -94,7 +96,8 @@ 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): @@ -110,7 +113,8 @@ 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*") @@ -121,7 +125,8 @@ 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): @@ -137,7 +142,8 @@ 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) @@ -372,7 +378,8 @@ 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): @@ -383,11 +390,14 @@ 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 @@ -395,7 +405,8 @@ 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): @@ -419,18 +430,22 @@ 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): @@ -443,11 +458,14 @@ 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*"]) @@ -463,7 +481,8 @@ 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 @@ -484,7 +503,8 @@ 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 c4805129935a379277d3191593f73707f7344909 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 26 Aug 2026 10:05:22 +0200 Subject: [PATCH 10/11] Use a real logging.Filter in the new caplog tests cast(logging.Filter, some_function) lies to the type checker. Subclass logging.Filter like test_with_statement_filtering right above does. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Code --- testing/logging/test_fixture.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 2972f32f890..388ba306342 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -4,7 +4,6 @@ from collections.abc import Iterator import logging -from typing import cast from _pytest.logging import caplog_records_key from _pytest.pytester import Pytester @@ -207,13 +206,17 @@ 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: +class DropAllFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: return False - with caplog.filtering(cast(logging.Filter, no_capture_filter)): + +def test_with_statement_nested_filtering(caplog: pytest.LogCaptureFixture) -> None: + drop_all = DropAllFilter() + + with caplog.filtering(drop_all): logger.warning("Will not be captured") - with caplog.filtering(cast(logging.Filter, no_capture_filter)): + with caplog.filtering(drop_all): logger.warning("Will also not be captured") logger.warning("Should not be captured either") @@ -223,19 +226,18 @@ 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 + drop_all = DropAllFilter() - caplog.handler.addFilter(no_capture_filter) + caplog.handler.addFilter(drop_all) try: - with caplog.filtering(cast(logging.Filter, no_capture_filter)): + with caplog.filtering(drop_all): 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) + caplog.handler.removeFilter(drop_all) @pytest.mark.parametrize( From 3174c1c2907a7033bc46e93b9d90831795551ecb Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 26 Aug 2026 10:05:53 +0200 Subject: [PATCH 11/11] Keep the add/remove pair in one branch in caplog.filtering Per @ncoghlan's review suggestion: skip the try/finally entirely when the filter is already present, so the add and its matching remove live in the same branch instead of behind two separate guards. No behaviour change. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Code --- src/_pytest/logging.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 48960bcf4bc..c55b5b622b8 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -586,12 +586,13 @@ def filtering(self, filter_: logging.Filter) -> Generator[None]: .. versionadded:: 7.5 """ already_present = filter_ in self.handler.filters - if not already_present: - self.handler.addFilter(filter_) - try: + if already_present: yield - finally: - if not already_present: + else: + try: + self.handler.addFilter(filter_) + yield + finally: self.handler.removeFilter(filter_)