Skip to content

Config.get_terminal_writer() crashes with AssertionError when terminalreporter is unregistered (pytest-tap streaming + non-test file asserts) #14377

Description

@antoineleclair

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions