From 464c65d9b242982685c2ca2b8595a221accc3d86 Mon Sep 17 00:00:00 2001 From: rachfop Date: Tue, 16 Jun 2026 10:59:59 -0700 Subject: [PATCH] Avoid IndexError when the pytest traceback cut has a single entry SybilItem._traceback_filter re-roots the displayed traceback at the frame below the example frame (tb[1]). When the example frame is the deepest in the traceback -- for example a failure surfaced from deferred or asynchronous evaluation -- the cut traceback is a single entry, so tb[1] raises IndexError from inside pytest failure reporting. That aborts the run with an INTERNALERROR and hides the actual failure. Guard the lookup so a single-entry cut keeps the cut traceback instead. --- src/sybil/integration/pytest.py | 9 ++++++--- tests/test_functional.py | 34 +++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/sybil/integration/pytest.py b/src/sybil/integration/pytest.py index 648802d..6651c47 100644 --- a/src/sybil/integration/pytest.py +++ b/src/sybil/integration/pytest.py @@ -80,9 +80,12 @@ def runtest(self) -> None: def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: traceback = excinfo.traceback tb = traceback.cut(path=example_module_path) - tb_entry = tb[1] - if getattr(tb_entry, '_rawentry', None) is not None: - traceback = Traceback(tb_entry._rawentry) + # ``tb`` has a single entry when the example frame is the deepest in + # the traceback, leaving no frame below it to re-root at. + if len(tb) > 1: + tb_entry = tb[1] + if getattr(tb_entry, '_rawentry', None) is not None: + traceback = Traceback(tb_entry._rawentry) return traceback def repr_failure( diff --git a/tests/test_functional.py b/tests/test_functional.py index 94a7fbe..4f4dc25 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -1,11 +1,15 @@ import sys +from inspect import getsourcefile +from os.path import abspath from pathlib import Path import pytest -from pytest import CaptureFixture +from pytest import CaptureFixture, ExceptionInfo from testfixtures import Replacer, compare, not_there -from sybil import Sybil +from sybil import Sybil, example as example_module +from sybil.document import Document +from sybil.integration.pytest import SybilItem from sybil.parsers.rest import PythonCodeBlockParser, DocTestParser from sybil.python import import_cleanup from .helpers import ( @@ -131,6 +135,32 @@ def test_pytest(capsys: CaptureFixture[str]): assert results.total == 10 +def test_traceback_filter_single_entry_cut() -> None: + # A failure whose deepest frame is the example frame cuts to a single + # traceback entry; filtering it must not raise IndexError. + source = getsourcefile(example_module) + assert source is not None + example_path = abspath(source) + + document = Document(".. code-block:: python\n\n raise ValueError('boom')\n", "x.rst") + for region in PythonCodeBlockParser()(document): + document.add(region) + + with pytest.raises(ValueError) as excinfo: + next(iter(document.examples())).evaluate() + + # Truncate so the example frame is the deepest, collapsing the cut to one. + raw = excinfo.tb + while raw is not None and raw.tb_frame.f_code.co_filename != example_path: + raw = raw.tb_next + assert raw is not None + raw.tb_next = None + + truncated = ExceptionInfo.from_exc_info((excinfo.type, excinfo.value, raw)) + result = SybilItem.__new__(SybilItem)._traceback_filter(truncated) + assert len(result) == 1 + + def test_pytest_output_immune_to_color_env(capsys: CaptureFixture[str]): with Replacer() as replace: replace.in_environ('PY_COLORS', not_there)