|
6 | 6 | from collections.abc import Callable
|
7 | 7 | from collections.abc import Generator
|
8 | 8 | from collections.abc import Iterable
|
| 9 | +from collections.abc import Iterator |
| 10 | +from enum import auto |
| 11 | +from enum import Enum |
9 | 12 | import inspect
|
10 | 13 | import sys
|
11 | 14 | import traceback
|
12 | 15 | import types
|
13 |
| -from typing import Any |
14 | 16 | from typing import TYPE_CHECKING
|
15 | 17 | from typing import Union
|
16 | 18 |
|
17 | 19 | import _pytest._code
|
18 | 20 | from _pytest.compat import is_async_function
|
19 | 21 | from _pytest.config import hookimpl
|
20 | 22 | from _pytest.fixtures import FixtureRequest
|
| 23 | +from _pytest.monkeypatch import MonkeyPatch |
21 | 24 | from _pytest.nodes import Collector
|
22 | 25 | from _pytest.nodes import Item
|
23 | 26 | from _pytest.outcomes import exit
|
@@ -228,8 +231,7 @@ def startTest(self, testcase: unittest.TestCase) -> None:
|
228 | 231 | pass
|
229 | 232 |
|
230 | 233 | def _addexcinfo(self, rawexcinfo: _SysExcInfoType) -> None:
|
231 |
| - # Unwrap potential exception info (see twisted trial support below). |
232 |
| - rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo) |
| 234 | + rawexcinfo = _handle_twisted_exc_info(rawexcinfo) |
233 | 235 | try:
|
234 | 236 | excinfo = _pytest._code.ExceptionInfo[BaseException].from_exc_info(
|
235 | 237 | rawexcinfo # type: ignore[arg-type]
|
@@ -385,49 +387,130 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None:
|
385 | 387 | call.excinfo = call2.excinfo
|
386 | 388 |
|
387 | 389 |
|
388 |
| -# Twisted trial support. |
389 |
| -classImplements_has_run = False |
| 390 | +def _is_skipped(obj) -> bool: |
| 391 | + """Return True if the given object has been marked with @unittest.skip.""" |
| 392 | + return bool(getattr(obj, "__unittest_skip__", False)) |
| 393 | + |
| 394 | + |
| 395 | +def pytest_configure() -> None: |
| 396 | + """Register the TestCaseFunction class as an IReporter if twisted.trial is available.""" |
| 397 | + if _get_twisted_version() is not TwistedVersion.NotInstalled: |
| 398 | + from twisted.trial.itrial import IReporter |
| 399 | + from zope.interface import classImplements |
| 400 | + |
| 401 | + classImplements(TestCaseFunction, IReporter) |
| 402 | + |
| 403 | + |
| 404 | +class TwistedVersion(Enum): |
| 405 | + """ |
| 406 | + The Twisted version installed in the environment. |
| 407 | +
|
| 408 | + We have different workarounds in place for different versions of Twisted. |
| 409 | + """ |
| 410 | + |
| 411 | + # Twisted version 24 or prior. |
| 412 | + Version24 = auto() |
| 413 | + # Twisted version 25 or later. |
| 414 | + Version25 = auto() |
| 415 | + # Twisted version is not available. |
| 416 | + NotInstalled = auto() |
| 417 | + |
| 418 | + |
| 419 | +def _get_twisted_version() -> TwistedVersion: |
| 420 | + # We need to check if "twisted.trial.unittest" is specifically present in sys.modules. |
| 421 | + # This is because we intend to integrate with Trial only when it's actively running |
| 422 | + # the test suite, but not needed when only other Twisted components are in use. |
| 423 | + if "twisted.trial.unittest" not in sys.modules: |
| 424 | + return TwistedVersion.NotInstalled |
| 425 | + |
| 426 | + import importlib.metadata |
| 427 | + |
| 428 | + import packaging.version |
| 429 | + |
| 430 | + version_str = importlib.metadata.version("twisted") |
| 431 | + version = packaging.version.parse(version_str) |
| 432 | + if version.major <= 24: |
| 433 | + return TwistedVersion.Version24 |
| 434 | + else: |
| 435 | + return TwistedVersion.Version25 |
| 436 | + |
| 437 | + |
| 438 | +# Name of the attribute in `twisted.python.Failure` instances that stores |
| 439 | +# the `sys.exc_info()` tuple. |
| 440 | +# See twisted.trial support in `pytest_runtest_protocol`. |
| 441 | +TWISTED_RAW_EXCINFO_ATTR = "_twisted_raw_excinfo" |
390 | 442 |
|
391 | 443 |
|
392 | 444 | @hookimpl(wrapper=True)
|
393 |
| -def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: |
394 |
| - if isinstance(item, TestCaseFunction) and "twisted.trial.unittest" in sys.modules: |
395 |
| - ut: Any = sys.modules["twisted.python.failure"] |
396 |
| - global classImplements_has_run |
397 |
| - Failure__init__ = ut.Failure.__init__ |
398 |
| - if not classImplements_has_run: |
399 |
| - from twisted.trial.itrial import IReporter |
400 |
| - from zope.interface import classImplements |
401 |
| - |
402 |
| - classImplements(TestCaseFunction, IReporter) |
403 |
| - classImplements_has_run = True |
404 |
| - |
405 |
| - def excstore( |
| 445 | +def pytest_runtest_protocol(item: Item) -> Iterator[None]: |
| 446 | + if _get_twisted_version() is TwistedVersion.Version24: |
| 447 | + import twisted.python.failure as ut |
| 448 | + |
| 449 | + # Monkeypatch `Failure.__init__` to store the raw exception info. |
| 450 | + original__init__ = ut.Failure.__init__ |
| 451 | + |
| 452 | + def store_raw_exception_info( |
406 | 453 | self, exc_value=None, exc_type=None, exc_tb=None, captureVars=None
|
407 |
| - ): |
| 454 | + ): # pragma: no cover |
408 | 455 | if exc_value is None:
|
409 |
| - self._rawexcinfo = sys.exc_info() |
| 456 | + raw_exc_info = sys.exc_info() |
410 | 457 | else:
|
411 | 458 | if exc_type is None:
|
412 | 459 | exc_type = type(exc_value)
|
413 |
| - self._rawexcinfo = (exc_type, exc_value, exc_tb) |
| 460 | + if exc_tb is None: |
| 461 | + exc_tb = sys.exc_info()[2] |
| 462 | + raw_exc_info = (exc_type, exc_value, exc_tb) |
| 463 | + setattr(self, TWISTED_RAW_EXCINFO_ATTR, tuple(raw_exc_info)) |
414 | 464 | try:
|
415 |
| - Failure__init__( |
| 465 | + original__init__( |
416 | 466 | self, exc_value, exc_type, exc_tb, captureVars=captureVars
|
417 | 467 | )
|
418 |
| - except TypeError: |
419 |
| - Failure__init__(self, exc_value, exc_type, exc_tb) |
| 468 | + except TypeError: # pragma: no cover |
| 469 | + original__init__(self, exc_value, exc_type, exc_tb) |
420 | 470 |
|
421 |
| - ut.Failure.__init__ = excstore |
422 |
| - try: |
423 |
| - res = yield |
424 |
| - finally: |
425 |
| - ut.Failure.__init__ = Failure__init__ |
| 471 | + with MonkeyPatch.context() as patcher: |
| 472 | + patcher.setattr(ut.Failure, "__init__", store_raw_exception_info) |
| 473 | + return (yield) |
426 | 474 | else:
|
427 |
| - res = yield |
428 |
| - return res |
429 |
| - |
430 |
| - |
431 |
| -def _is_skipped(obj) -> bool: |
432 |
| - """Return True if the given object has been marked with @unittest.skip.""" |
433 |
| - return bool(getattr(obj, "__unittest_skip__", False)) |
| 475 | + return (yield) |
| 476 | + |
| 477 | + |
| 478 | +def _handle_twisted_exc_info( |
| 479 | + rawexcinfo: _SysExcInfoType | BaseException, |
| 480 | +) -> _SysExcInfoType: |
| 481 | + """ |
| 482 | + Twisted passes a custom Failure instance to `addError()` instead of using `sys.exc_info()`. |
| 483 | + Therefore, if `rawexcinfo` is a `Failure` instance, convert it into the equivalent `sys.exc_info()` tuple |
| 484 | + as expected by pytest. |
| 485 | + """ |
| 486 | + twisted_version = _get_twisted_version() |
| 487 | + if twisted_version is TwistedVersion.NotInstalled: |
| 488 | + # Unfortunately, because we cannot import `twisted.python.failure` at the top of the file |
| 489 | + # and use it in the signature, we need to use `type:ignore` here because we cannot narrow |
| 490 | + # the type properly in the `if` statement above. |
| 491 | + return rawexcinfo # type:ignore[return-value] |
| 492 | + elif twisted_version is TwistedVersion.Version24: |
| 493 | + # Twisted calls addError() passing its own classes (like `twisted.python.Failure`), which violates |
| 494 | + # the `addError()` signature, so we extract the original `sys.exc_info()` tuple which is stored |
| 495 | + # in the object. |
| 496 | + if hasattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR): |
| 497 | + saved_exc_info = getattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR) |
| 498 | + # Delete the attribute from the original object to avoid leaks. |
| 499 | + delattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR) |
| 500 | + return saved_exc_info # type:ignore[no-any-return] |
| 501 | + return rawexcinfo # type:ignore[return-value] |
| 502 | + elif twisted_version is TwistedVersion.Version25: |
| 503 | + if isinstance(rawexcinfo, BaseException): |
| 504 | + import twisted.python.failure |
| 505 | + |
| 506 | + if isinstance(rawexcinfo, twisted.python.failure.Failure): |
| 507 | + tb = rawexcinfo.__traceback__ |
| 508 | + if tb is None: |
| 509 | + tb = sys.exc_info()[2] |
| 510 | + return type(rawexcinfo.value), rawexcinfo.value, tb |
| 511 | + |
| 512 | + return rawexcinfo # type:ignore[return-value] |
| 513 | + else: |
| 514 | + # Ideally we would use assert_never() here, but it is not available in all Python versions |
| 515 | + # we support, plus we do not require `type_extensions` currently. |
| 516 | + assert False, f"Unexpected Twisted version: {twisted_version}" |
0 commit comments