|
9 | 9 | from io import StringIO
|
10 | 10 | import os
|
11 | 11 | from pprint import pprint
|
| 12 | +import sys |
12 | 13 | from typing import Any
|
13 | 14 | from typing import cast
|
14 | 15 | from typing import final
|
|
33 | 34 | from _pytest.nodes import Item
|
34 | 35 | from _pytest.outcomes import fail
|
35 | 36 | from _pytest.outcomes import skip
|
| 37 | +from _pytest.outcomes import Skipped |
| 38 | + |
| 39 | + |
| 40 | +if sys.version_info < (3, 11): |
| 41 | + from exceptiongroup import BaseExceptionGroup |
36 | 42 |
|
37 | 43 |
|
38 | 44 | if TYPE_CHECKING:
|
@@ -251,6 +257,46 @@ def _report_unserialization_failure(
|
251 | 257 | raise RuntimeError(stream.getvalue())
|
252 | 258 |
|
253 | 259 |
|
| 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: # exception in setup or teardown |
| 266 | + longrepr = item._repr_failure_py( |
| 267 | + excinfo, style=item.config.getoption("tbstyle", "auto") |
| 268 | + ) |
| 269 | + return longrepr |
| 270 | + |
| 271 | + |
| 272 | +def _format_exception_group_all_skipped_longrepr( |
| 273 | + item: Item, |
| 274 | + excinfo: ExceptionInfo[BaseException], |
| 275 | + exceptions: list[Skipped], |
| 276 | +) -> tuple[str, int, str]: |
| 277 | + if any(getattr(skip, "_use_item_location", False) for skip in exceptions): |
| 278 | + path, line = item.reportinfo()[:2] |
| 279 | + assert line is not None |
| 280 | + loc = (os.fspath(path), line + 1) |
| 281 | + default_msg = "skipped" |
| 282 | + else: |
| 283 | + r = excinfo._getreprcrash() |
| 284 | + assert r is not None |
| 285 | + loc = (str(r.path), r.lineno) |
| 286 | + default_msg = r.message |
| 287 | + |
| 288 | + # reason(s): order-preserving de-dupe, same fields as single-skip |
| 289 | + msgs: list[str] = [] |
| 290 | + for exception in exceptions: |
| 291 | + m = exception.msg or exception.args[0] |
| 292 | + if m and m not in msgs: |
| 293 | + msgs.append(m) |
| 294 | + |
| 295 | + reason = "; ".join(msgs) if msgs else default_msg |
| 296 | + longrepr = (*loc, reason) |
| 297 | + return longrepr |
| 298 | + |
| 299 | + |
254 | 300 | @final
|
255 | 301 | class TestReport(BaseReport):
|
256 | 302 | """Basic test report object (also used for setup and teardown calls if
|
@@ -368,17 +414,27 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
|
368 | 414 | if excinfo.value._use_item_location:
|
369 | 415 | path, line = item.reportinfo()[:2]
|
370 | 416 | assert line is not None
|
371 |
| - longrepr = os.fspath(path), line + 1, r.message |
| 417 | + longrepr = (os.fspath(path), line + 1, r.message) |
372 | 418 | else:
|
373 | 419 | longrepr = (str(r.path), r.lineno, r.message)
|
| 420 | + elif isinstance(excinfo.value, BaseExceptionGroup): |
| 421 | + value: BaseExceptionGroup = excinfo.value |
| 422 | + if value.exceptions and all( |
| 423 | + isinstance(exception, skip.Exception) |
| 424 | + for exception in value.exceptions |
| 425 | + ): |
| 426 | + outcome = "skipped" |
| 427 | + skipped_exceptions = cast(list[Skipped], value.exceptions) |
| 428 | + longrepr = _format_exception_group_all_skipped_longrepr( |
| 429 | + item, excinfo, skipped_exceptions |
| 430 | + ) |
| 431 | + else: |
| 432 | + # fall through to your existing failure path |
| 433 | + outcome = "failed" |
| 434 | + longrepr = _format_failed_longrepr(item, call, excinfo) |
374 | 435 | else:
|
375 | 436 | 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 |
| - ) |
| 437 | + longrepr = _format_failed_longrepr(item, call, excinfo) |
382 | 438 | for rwhen, key, content in item._report_sections:
|
383 | 439 | sections.append((f"Captured {key} {rwhen}", content))
|
384 | 440 | return cls(
|
|
0 commit comments