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
terminalreporterplugin (notablypytest-tapin streaming mode —--tap-stream), any failingassert a == bin a rewritten module crashes pytest's assertion-diff machinery with a confusing internalAssertionErrorthat masks the real test failure.How to reproduce
We run a large test suite in CI with:
pytest-tapin streaming mode unregisters theterminalreporterplugin so it can produce its own TAP output without pytest's terminal output interleaving. Seepytest_tap/plugin.py:We also have a helper module (imported by test files, but not itself a test file) that uses
assertinside 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_compareto build a nice diff. That function callsconfig.get_terminal_writer()._highlightfor syntax highlighting — butget_terminal_writer()unconditionally asserts that theterminalreporterplugin is registered:Since
pytest-tapunregistered it, this assert explodes and the resulting traceback replaces the real failure message, making the actual test failure invisible.Traceback (anonymized)
The real failure (
wait_for_tasktimed out because the task status never reachedCOMPLETED) is completely gone — replaced by this cryptic internal assert.Minimal reproducer
Run with plain
pytest test_foo.py— the test failure's longrepr contains theterminalreporter is not Nonecrash 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-planExpected behavior
Config.get_terminal_writer()should gracefully return a usableTerminalWriterwhenterminalreporterhas been unregistered, so downstream code (assertion diffing,show_test_item,setuponly) keeps working. The natural fallback iscreate_terminal_writer(self)— the same factoryTerminalReporter.__init__uses internally — so consumers stay consistent with the normal path.Versions
pip list(relevant packages)