From 691deb510858cc8430b3658cdcd4e37673a67a67 Mon Sep 17 00:00:00 2001 From: Antoine Leclair Date: Fri, 10 Apr 2026 16:50:18 -0400 Subject: [PATCH] Fix Config.get_terminal_writer() crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a plugin such as pytest-tap in streaming mode unregisters the terminalreporter plugin, Config.get_terminal_writer() crashed with an internal AssertionError that masked the real test failures. Fall back to create_terminal_writer(self) — the same factory TerminalReporter uses internally — so assertion rewriting, show_test_item, and setuponly keep working. Fixes #14377. Co-Authored-By: Claude Opus 4.6 (1M context) --- AUTHORS | 1 + changelog/14377.bugfix.rst | 1 + src/_pytest/config/__init__.py | 3 ++- testing/test_assertion.py | 23 +++++++++++++++++++++++ testing/test_config.py | 9 +++++++++ 5 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 changelog/14377.bugfix.rst diff --git a/AUTHORS b/AUTHORS index f3c8d016c28..b204cc7039f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -44,6 +44,7 @@ Anna Tasiopoulou Anthon van der Neut Anthony Shaw Anthony Sottile +Antoine Leclair Anton Grinevich Anton Lodder Anton Zhilin diff --git a/changelog/14377.bugfix.rst b/changelog/14377.bugfix.rst new file mode 100644 index 00000000000..ce3569f10f9 --- /dev/null +++ b/changelog/14377.bugfix.rst @@ -0,0 +1 @@ +Fixed :meth:`pytest.Config.get_terminal_writer` crashing with an internal ``AssertionError`` when the terminal reporter plugin has been unregistered (for example, by :pypi:`pytest-tap` in streaming mode). A fresh terminal writer is now created on demand. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index f7c4de5d7e9..cbe799b7ebd 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1173,7 +1173,8 @@ def get_terminal_writer(self) -> TerminalWriter: terminalreporter: TerminalReporter | None = self.pluginmanager.get_plugin( "terminalreporter" ) - assert terminalreporter is not None + if terminalreporter is None: + return create_terminal_writer(self) return terminalreporter._tw def pytest_cmdline_parse( diff --git a/testing/test_assertion.py b/testing/test_assertion.py index d68fd0b1fba..f783fdc18b7 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1682,6 +1682,29 @@ def test_hello(): ) +def test_assertrepr_compare_without_terminalreporter(pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(trylast=True) + def pytest_configure(config): + reporter = config.pluginmanager.get_plugin("terminalreporter") + config.pluginmanager.unregister(reporter) + """ + ) + pytester.makepyfile( + """ + def test_hello(): + assert "actual" == "expected" + """ + ) + reprec = pytester.inline_run() + _passed, _skipped, failed = reprec.listoutcomes() + assert len(failed) == 1 + assert "assert 'actual' == 'expected'" in str(failed[0].longrepr) + + def test_assertrepr_loaded_per_dir(pytester: Pytester) -> None: pytester.makepyfile(test_base=["def test_base(): assert 1 == 2"]) a = pytester.mkdir("a") diff --git a/testing/test_config.py b/testing/test_config.py index 296461c12fc..c9d331b7ee8 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1386,6 +1386,15 @@ def cleanup_first(): assert report == ["cleanup_first", "raise_1", "raise_2", "cleanup_last"] + def test_get_terminal_writer_without_terminalreporter( + self, pytester: Pytester + ) -> None: + from _pytest._io import TerminalWriter + + config = pytester.parseconfig() + config.pluginmanager.unregister(name="terminalreporter") + assert isinstance(config.get_terminal_writer(), TerminalWriter) + class TestConfigFromdictargs: def test_basic_behavior(self, _sys_snapshot) -> None: