Description
When a plugin unregisters the terminalreporter plugin (notably pytest-tap in streaming mode — --tap-stream), any failing assert a == b in a rewritten module crashes pytest's assertion-diff machinery with a confusing internal AssertionError that masks the real test failure.
How to reproduce
We run a large test suite in CI with:
pytest --tap-stream -n 3 ...
pytest-tap in streaming mode unregisters the terminalreporter plugin so it can produce its own TAP output without pytest's terminal output interleaving. See pytest_tap/plugin.py:
if self._tracker.streaming:
reporter = config.pluginmanager.getplugin("terminalreporter")
if reporter:
config.pluginmanager.unregister(reporter)
We also have a helper module (imported by test files, but not itself a test file) that uses assert inside a polling loop, roughly:
# tests/utils/helpers.py
def wait_for_task(task_url, expected_status="COMPLETED", timeout=30, interval=0.1):
def check():
response = requests.get(task_url)
assert response.status_code == 200
assert response.json()["task"]["status"] == expected_status
return response.json()["task"]
return wait(check, timeout=timeout, interval=interval)
When that second assertion fails (e.g. a legitimately slow task that times out), pytest's assertion rewriting triggers _pytest.assertion.util.assertrepr_compare to build a nice diff. That function calls config.get_terminal_writer()._highlight for syntax highlighting — but get_terminal_writer() unconditionally asserts that the terminalreporter plugin is registered:
def get_terminal_writer(self) -> TerminalWriter:
terminalreporter: TerminalReporter | None = self.pluginmanager.get_plugin(
"terminalreporter"
)
assert terminalreporter is not None
return terminalreporter._tw
Since pytest-tap unregistered it, this assert explodes and the resulting traceback replaces the real failure message, making the actual test failure invisible.
Traceback (anonymized)
self = <tests.some_feature_test.SomeFeatureTest testMethod=test_some_scenario>
def test_some_scenario(self):
...
> self._trigger_async_action()
tests/some_feature_test.py:415:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/some_feature_test.py:587: in _trigger_async_action
wait_for_task(task_url)
tests/utils/helpers.py:211: in wait_for_task
return wait(check, timeout=timeout, interval=interval)
tests/utils/helpers.py:200: in wait
return func()
tests/utils/helpers.py:208: in check
assert response.json()["task"]["status"] == expected_status
/usr/local/lib/python3.13/site-packages/_pytest/assertion/rewrite.py:507: in _call_reprcompare
custom = util._reprcompare(ops[i], each_obj[i], each_obj[i + 1])
/usr/local/lib/python3.13/site-packages/_pytest/assertion/__init__.py:167: in callbinrepr
hook_result = ihook.pytest_assertrepr_compare(
/usr/local/lib/python3.13/site-packages/pluggy/_hooks.py:512: in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
/usr/local/lib/python3.13/site-packages/_pytest/assertion/__init__.py:208: in pytest_assertrepr_compare
return util.assertrepr_compare(config=config, op=op, left=left, right=right)
/usr/local/lib/python3.13/site-packages/_pytest/assertion/util.py:206: in assertrepr_compare
highlighter = config.get_terminal_writer()._highlight
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <_pytest.config.Config object at 0x78e49b616510>
def get_terminal_writer(self) -> TerminalWriter:
terminalreporter: TerminalReporter | None = self.pluginmanager.get_plugin(
"terminalreporter"
)
> assert terminalreporter is not None
E AssertionError
/usr/local/lib/python3.13/site-packages/_pytest/config/__init__.py:1179: AssertionError
The real failure (wait_for_task timed out because the task status never reached COMPLETED) is completely gone — replaced by this cryptic internal assert.
Minimal reproducer
# conftest.py
import pytest
@pytest.hookimpl(trylast=True)
def pytest_configure(config):
reporter = config.pluginmanager.get_plugin("terminalreporter")
config.pluginmanager.unregister(reporter)
# test_foo.py
def test_hello():
assert "actual" == "expected"
Run with plain pytest test_foo.py — the test failure's longrepr contains the terminalreporter is not None crash instead of the assertion diff.
Other affected call sites
Two other places in pytest call Config.get_terminal_writer() and would hit the same crash in similar niche configurations:
- _pytest/runner.py::show_test_item — used under --collect-only -q
- _pytest/setuponly.py::_show_fixture_action — used under --setup-only / --setup-plan
Expected behavior
Config.get_terminal_writer() should gracefully return a usable TerminalWriter when terminalreporter has been unregistered, so downstream code (assertion diffing, show_test_item, setuponly) keeps working. The natural fallback is create_terminal_writer(self) — the same factory TerminalReporter.__init__ uses internally — so consumers stay consistent with the normal path.
Versions
- pytest 9.0.2
- pytest-tap 3.5
- pytest-xdist 3.8.0
- pluggy 1.6.0
- Python 3.13
- Linux
pip list (relevant packages)
iniconfig 2.3.0
packaging 26.0
pluggy 1.6.0
pytest 9.0.2
pytest-tap 3.5
pytest-xdist 3.8.0
tap.py 3.2.1
Description
When a plugin unregisters the terminalreporter plugin (notably pytest-tap in streaming mode — --tap-stream), any failing assert a == b in a rewritten module crashes pytest's assertion-diff machinery with a confusing internal AssertionError that masks the real test failure.
How to reproduce
We run a large test suite in CI with:
pytest-tap in streaming mode unregisters the terminalreporter plugin so it can produce its own TAP output without pytest's terminal output interleaving. See pytest_tap/plugin.py:
We also have a helper module (imported by test files, but not itself a test file) that uses assert inside a polling loop, roughly:
When that second assertion fails (e.g. a legitimately slow task that times out), pytest's assertion rewriting triggers _pytest.assertion.util.assertrepr_compare to build a nice diff. That function calls config.get_terminal_writer()._highlight for syntax highlighting — but get_terminal_writer() unconditionally asserts that the terminalreporter plugin is registered:
Since pytest-tap unregistered it, this assert explodes and the resulting traceback replaces the real failure message, making the actual test failure invisible.
Traceback (anonymized)
self = <tests.some_feature_test.SomeFeatureTest testMethod=test_some_scenario> def test_some_scenario(self): ... > self._trigger_async_action() tests/some_feature_test.py:415: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ tests/some_feature_test.py:587: in _trigger_async_action wait_for_task(task_url) tests/utils/helpers.py:211: in wait_for_task return wait(check, timeout=timeout, interval=interval) tests/utils/helpers.py:200: in wait return func() tests/utils/helpers.py:208: in check assert response.json()["task"]["status"] == expected_status /usr/local/lib/python3.13/site-packages/_pytest/assertion/rewrite.py:507: in _call_reprcompare custom = util._reprcompare(ops[i], each_obj[i], each_obj[i + 1]) /usr/local/lib/python3.13/site-packages/_pytest/assertion/__init__.py:167: in callbinrepr hook_result = ihook.pytest_assertrepr_compare( /usr/local/lib/python3.13/site-packages/pluggy/_hooks.py:512: in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) /usr/local/lib/python3.13/site-packages/_pytest/assertion/__init__.py:208: in pytest_assertrepr_compare return util.assertrepr_compare(config=config, op=op, left=left, right=right) /usr/local/lib/python3.13/site-packages/_pytest/assertion/util.py:206: in assertrepr_compare highlighter = config.get_terminal_writer()._highlight _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <_pytest.config.Config object at 0x78e49b616510> def get_terminal_writer(self) -> TerminalWriter: terminalreporter: TerminalReporter | None = self.pluginmanager.get_plugin( "terminalreporter" ) > assert terminalreporter is not None E AssertionError /usr/local/lib/python3.13/site-packages/_pytest/config/__init__.py:1179: AssertionErrorThe real failure (wait_for_task timed out because the task status never reached COMPLETED) is completely gone — replaced by this cryptic internal assert.
Minimal reproducer
Run with plain pytest test_foo.py — the test failure's longrepr contains the terminalreporter is not None crash instead of the assertion diff.
Other affected call sites
Two other places in pytest call Config.get_terminal_writer() and would hit the same crash in similar niche configurations:
Expected behavior
Config.get_terminal_writer() should gracefully return a usable TerminalWriter when terminalreporter has been unregistered, so downstream code (assertion diffing, show_test_item, setuponly) keeps working. The natural fallback is create_terminal_writer(self) — the same factory TerminalReporter.__init__ uses internally — so consumers stay consistent with the normal path.
Versions
pip list (relevant packages)