Skip to content

Commit 11ad3fb

Browse files
committed
fix #13537: Add support for ExceptionGroup with only Skipped exceptions in teardown
1 parent c97a401 commit 11ad3fb

File tree

4 files changed

+256
-7
lines changed

4 files changed

+256
-7
lines changed

changelog/13537.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix bug in which ExceptionGroup with only Skipped exceptions in teardown was not handled correctly and showed as error.

src/_pytest/compat.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@
1212
import os
1313
from pathlib import Path
1414
import sys
15+
import types
16+
from types import TracebackType
1517
from typing import Any
1618
from typing import Final
1719
from typing import NoReturn
1820

1921
import py
2022

2123

24+
if sys.version_info < (3, 11):
25+
from exceptiongroup import BaseExceptionGroup
26+
2227
if sys.version_info >= (3, 14):
2328
from annotationlib import Format
2429

@@ -311,3 +316,74 @@ def running_on_ci() -> bool:
311316
# Only enable CI mode if one of these env variables is defined and non-empty.
312317
env_vars = ["CI", "BUILD_NUMBER"]
313318
return any(os.environ.get(var) for var in env_vars)
319+
320+
321+
# https://peps.python.org/pep-0785/
322+
# Copied Implementation of PEP 785 for flattening BaseExceptionGroups.
323+
# Commented-out code is disabled for now as it is unused.
324+
def leaf_exceptions(
325+
exception_group: BaseExceptionGroup,
326+
# *,
327+
# fix_traceback: bool = True
328+
) -> list[BaseException]:
329+
"""
330+
Return a flat list of all 'leaf' exceptions.
331+
332+
If fix_tracebacks is True, each leaf will have the traceback replaced
333+
with a composite so that frames attached to intermediate groups are
334+
still visible when debugging. Pass fix_tracebacks=False to disable
335+
this modification, e.g. if you expect to raise the group unchanged.
336+
"""
337+
338+
def _flatten(
339+
group: BaseExceptionGroup,
340+
# fix_tracebacks: bool,
341+
parent_tb: TracebackType | None = None,
342+
) -> list[BaseException]:
343+
group_tb = group.__traceback__
344+
combined_tb = _combine_tracebacks(parent_tb, group_tb)
345+
result = []
346+
for exc in group.exceptions:
347+
if isinstance(exc, BaseExceptionGroup):
348+
result.extend(_flatten(exc, combined_tb))
349+
# elif fix_tracebacks:
350+
# tb = _combine_tracebacks(combined_tb, exc.__traceback__)
351+
# result.append(exc.with_traceback(tb))
352+
else:
353+
result.append(exc)
354+
return result
355+
356+
return _flatten(exception_group)
357+
358+
359+
def _combine_tracebacks(
360+
tb1: TracebackType | None,
361+
tb2: TracebackType | None,
362+
) -> TracebackType | None:
363+
"""
364+
Combine two tracebacks, putting tb1 frames before tb2 frames.
365+
366+
If either is None, return the other.
367+
"""
368+
if tb1 is None:
369+
return tb2
370+
if tb2 is None:
371+
return tb1
372+
373+
# Convert tb1 to a list of frames.
374+
frames = []
375+
current: TracebackType | None = tb1
376+
while current is not None:
377+
frames.append((current.tb_frame, current.tb_lasti, current.tb_lineno))
378+
current = current.tb_next
379+
380+
# Create a new traceback starting with tb2.
381+
new_tb = tb2
382+
383+
# Add frames from tb1 to the beginning (in reverse order).
384+
for frame, lasti, lineno in reversed(frames):
385+
new_tb = types.TracebackType(
386+
tb_next=new_tb, tb_frame=frame, tb_lasti=lasti, tb_lineno=lineno
387+
)
388+
389+
return new_tb

src/_pytest/reports.py

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from io import StringIO
1010
import os
1111
from pprint import pprint
12+
import sys
1213
from typing import Any
1314
from typing import cast
1415
from typing import final
@@ -28,13 +29,18 @@
2829
from _pytest._code.code import ReprTraceback
2930
from _pytest._code.code import TerminalRepr
3031
from _pytest._io import TerminalWriter
32+
from _pytest.compat import leaf_exceptions
3133
from _pytest.config import Config
3234
from _pytest.nodes import Collector
3335
from _pytest.nodes import Item
3436
from _pytest.outcomes import fail
3537
from _pytest.outcomes import skip
3638

3739

40+
if sys.version_info < (3, 11):
41+
from exceptiongroup import BaseExceptionGroup
42+
43+
3844
if TYPE_CHECKING:
3945
from typing_extensions import Self
4046

@@ -251,6 +257,53 @@ def _report_unserialization_failure(
251257
raise RuntimeError(stream.getvalue())
252258

253259

260+
def _format_failed_longrepr(
261+
item: Item, call: CallInfo[None], excinfo: ExceptionInfo[BaseException]
262+
):
263+
if call.when == "call":
264+
longrepr = item.repr_failure(excinfo)
265+
else:
266+
# Exception in setup or teardown.
267+
longrepr = item._repr_failure_py(
268+
excinfo, style=item.config.getoption("tbstyle", "auto")
269+
)
270+
return longrepr
271+
272+
273+
def _format_exception_group_all_skipped_longrepr(
274+
item: Item,
275+
excinfo: ExceptionInfo[BaseExceptionGroup[BaseException | BaseExceptionGroup]],
276+
) -> tuple[str, int, str]:
277+
r = excinfo._getreprcrash()
278+
assert r is not None, (
279+
"There should always be a traceback entry for skipping a test."
280+
)
281+
if all(
282+
getattr(skip, "_use_item_location", False) for skip in excinfo.value.exceptions
283+
):
284+
path, line = item.reportinfo()[:2]
285+
assert line is not None
286+
loc = (os.fspath(path), line + 1)
287+
default_msg = "skipped"
288+
else:
289+
loc = (str(r.path), r.lineno)
290+
default_msg = r.message
291+
292+
# Get all unique skip messages.
293+
msgs: list[str] = []
294+
flatten_exceptions = leaf_exceptions(excinfo.value)
295+
for exception in flatten_exceptions:
296+
m = getattr(exception, "msg", None) or (
297+
exception.args[0] if exception.args else None
298+
)
299+
if m and m not in msgs:
300+
msgs.append(m)
301+
302+
reason = "; ".join(msgs) if msgs else default_msg
303+
longrepr = (*loc, reason)
304+
return longrepr
305+
306+
254307
@final
255308
class TestReport(BaseReport):
256309
"""Basic test report object (also used for setup and teardown calls if
@@ -368,17 +421,24 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
368421
if excinfo.value._use_item_location:
369422
path, line = item.reportinfo()[:2]
370423
assert line is not None
371-
longrepr = os.fspath(path), line + 1, r.message
424+
longrepr = (os.fspath(path), line + 1, r.message)
372425
else:
373426
longrepr = (str(r.path), r.lineno, r.message)
427+
elif isinstance(excinfo.value, BaseExceptionGroup) and (
428+
excinfo.value.split(skip.Exception)[1] is None
429+
):
430+
# All exceptions in the group are skip exceptions.
431+
outcome = "skipped"
432+
excinfo = cast(
433+
ExceptionInfo[
434+
BaseExceptionGroup[BaseException | BaseExceptionGroup]
435+
],
436+
excinfo,
437+
)
438+
longrepr = _format_exception_group_all_skipped_longrepr(item, excinfo)
374439
else:
375440
outcome = "failed"
376-
if call.when == "call":
377-
longrepr = item.repr_failure(excinfo)
378-
else: # exception in setup or teardown
379-
longrepr = item._repr_failure_py(
380-
excinfo, style=item.config.getoption("tbstyle", "auto")
381-
)
441+
longrepr = _format_failed_longrepr(item, call, excinfo)
382442
for rwhen, key, content in item._report_sections:
383443
sections.append((f"Captured {key} {rwhen}", content))
384444
return cls(

testing/test_reports.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,118 @@ def test_1(fixture_): timing.sleep(10)
434434
loaded_report = TestReport._from_json(data)
435435
assert loaded_report.stop - loaded_report.start == approx(report.duration)
436436

437+
@pytest.mark.parametrize(
438+
"first_skip_reason, second_skip_reason, skip_reason_output",
439+
[("A", "B", "(A; B)"), ("A", "A", "(A)")],
440+
)
441+
def test_exception_group_with_only_skips(
442+
self,
443+
pytester: Pytester,
444+
first_skip_reason: str,
445+
second_skip_reason: str,
446+
skip_reason_output: str,
447+
):
448+
"""
449+
Test that when an ExceptionGroup with only Skipped exceptions is raised in teardown,
450+
it is reported as a single skipped test, not as an error.
451+
This is a regression test for issue #13537.
452+
"""
453+
pytester.makepyfile(
454+
test_it=f"""
455+
import pytest
456+
@pytest.fixture
457+
def fixA():
458+
yield
459+
pytest.skip(reason="{first_skip_reason}")
460+
@pytest.fixture
461+
def fixB():
462+
yield
463+
pytest.skip(reason="{second_skip_reason}")
464+
def test_skip(fixA, fixB):
465+
assert True
466+
"""
467+
)
468+
result = pytester.runpytest("-v")
469+
result.assert_outcomes(passed=1, skipped=1)
470+
out = result.stdout.str()
471+
assert skip_reason_output in out
472+
assert "ERROR at teardown" not in out
473+
474+
@pytest.mark.parametrize(
475+
"use_item_location, skip_file_location",
476+
[(True, "test_it.py"), (False, "runner.py")],
477+
)
478+
def test_exception_group_skips_use_item_location(
479+
self, pytester: Pytester, use_item_location: bool, skip_file_location: str
480+
):
481+
"""
482+
Regression for #13537:
483+
If any skip inside an ExceptionGroup has _use_item_location=True,
484+
the report location should point to the test item, not the fixture teardown.
485+
"""
486+
pytester.makepyfile(
487+
test_it=f"""
488+
import pytest
489+
@pytest.fixture
490+
def fix_item1():
491+
yield
492+
exc = pytest.skip.Exception("A")
493+
exc._use_item_location = True
494+
raise exc
495+
@pytest.fixture
496+
def fix_item2():
497+
yield
498+
exc = pytest.skip.Exception("B")
499+
exc._use_item_location = {use_item_location}
500+
raise exc
501+
def test_both(fix_item1, fix_item2):
502+
assert True
503+
"""
504+
)
505+
result = pytester.runpytest("-rs")
506+
result.assert_outcomes(passed=1, skipped=1)
507+
508+
out = result.stdout.str()
509+
# Both reasons should appear
510+
assert "A" and "B" in out
511+
# Crucially, the skip should be attributed to the test item, not teardown
512+
assert skip_file_location in out
513+
514+
def test_nested_exception_groups_with_skips(
515+
self,
516+
pytester: Pytester,
517+
):
518+
"""
519+
Test that when nested ExceptionGroups with Skipped exceptions are raised in teardown,
520+
they are reported as a single skipped test, not as an error.
521+
This is a regression test for issue #13537.
522+
"""
523+
pytester.makepyfile(
524+
test_it="""
525+
import pytest
526+
@pytest.fixture
527+
def fix_item1():
528+
yield
529+
exc1 = pytest.skip.Exception("A1")
530+
exc2 = pytest.skip.Exception("A2")
531+
exc3 = pytest.skip.Exception("B1")
532+
exc4 = pytest.skip.Exception("B2")
533+
eg = BaseExceptionGroup("Group B", [exc3, exc4])
534+
raise BaseExceptionGroup("Group A", [exc1, exc2, eg])
535+
def test_skip(fix_item1):
536+
assert True
537+
"""
538+
)
539+
result = pytester.runpytest("-v")
540+
result.assert_outcomes(passed=1, skipped=1)
541+
out = result.stdout.str()
542+
# All skip reasons should appear
543+
expected_reasons = ["A1", "A2", "B1", "B2"]
544+
assert all(reason in out for reason in expected_reasons)
545+
assert "ERROR at teardown" not in out
546+
547+
# TODO: add test multiple level nested ExceptionGroups with skips
548+
437549

438550
class TestHooks:
439551
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)