|
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 |
|
35 | 36 | from _pytest.outcomes import skip |
36 | 37 |
|
37 | 38 |
|
| 39 | +if sys.version_info < (3, 11): |
| 40 | + from exceptiongroup import BaseExceptionGroup |
| 41 | + |
| 42 | + |
38 | 43 | if TYPE_CHECKING: |
39 | 44 | from typing_extensions import Self |
40 | 45 |
|
@@ -251,6 +256,52 @@ def _report_unserialization_failure( |
251 | 256 | raise RuntimeError(stream.getvalue()) |
252 | 257 |
|
253 | 258 |
|
| 259 | +def _format_failed_longrepr( |
| 260 | + item: Item, call: CallInfo[None], excinfo: ExceptionInfo[BaseException] |
| 261 | +): |
| 262 | + if call.when == "call": |
| 263 | + longrepr = item.repr_failure(excinfo) |
| 264 | + else: |
| 265 | + # 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[BaseExceptionGroup[BaseException | BaseExceptionGroup]], |
| 275 | +) -> tuple[str, int, str]: |
| 276 | + r = excinfo._getreprcrash() |
| 277 | + assert r is not None, ( |
| 278 | + "There should always be a traceback entry for skipping a test." |
| 279 | + ) |
| 280 | + if all( |
| 281 | + getattr(skip, "_use_item_location", False) for skip in excinfo.value.exceptions |
| 282 | + ): |
| 283 | + path, line = item.reportinfo()[:2] |
| 284 | + assert line is not None |
| 285 | + loc = (os.fspath(path), line + 1) |
| 286 | + default_msg = "skipped" |
| 287 | + else: |
| 288 | + loc = (str(r.path), r.lineno) |
| 289 | + default_msg = r.message |
| 290 | + |
| 291 | + # Get all unique skip messages. |
| 292 | + msgs: list[str] = [] |
| 293 | + for exception in excinfo.value.exceptions: |
| 294 | + m = getattr(exception, "msg", None) or ( |
| 295 | + exception.args[0] if exception.args else None |
| 296 | + ) |
| 297 | + if m and m not in msgs: |
| 298 | + msgs.append(m) |
| 299 | + |
| 300 | + reason = "; ".join(msgs) if msgs else default_msg |
| 301 | + longrepr = (*loc, reason) |
| 302 | + return longrepr |
| 303 | + |
| 304 | + |
254 | 305 | @final |
255 | 306 | class TestReport(BaseReport): |
256 | 307 | """Basic test report object (also used for setup and teardown calls if |
@@ -368,17 +419,24 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: |
368 | 419 | if excinfo.value._use_item_location: |
369 | 420 | path, line = item.reportinfo()[:2] |
370 | 421 | assert line is not None |
371 | | - longrepr = os.fspath(path), line + 1, r.message |
| 422 | + longrepr = (os.fspath(path), line + 1, r.message) |
372 | 423 | else: |
373 | 424 | longrepr = (str(r.path), r.lineno, r.message) |
| 425 | + elif isinstance(excinfo.value, BaseExceptionGroup) and ( |
| 426 | + excinfo.value.split(skip.Exception)[1] is None |
| 427 | + ): |
| 428 | + # All exceptions in the group are skip exceptions. |
| 429 | + outcome = "skipped" |
| 430 | + excinfo = cast( |
| 431 | + ExceptionInfo[ |
| 432 | + BaseExceptionGroup[BaseException | BaseExceptionGroup] |
| 433 | + ], |
| 434 | + excinfo, |
| 435 | + ) |
| 436 | + longrepr = _format_exception_group_all_skipped_longrepr(item, excinfo) |
374 | 437 | else: |
375 | 438 | 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 | | - ) |
| 439 | + longrepr = _format_failed_longrepr(item, call, excinfo) |
382 | 440 | for rwhen, key, content in item._report_sections: |
383 | 441 | sections.append((f"Captured {key} {rwhen}", content)) |
384 | 442 | return cls( |
|
0 commit comments