Skip to content

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

@antoineleclair

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions