Skip to content

Commit c48c810

Browse files
committed
Twisted support: extract sys.exc_info during addError
Alternative fix for #13497: no longer monkeypatch `Failure.__init__`, instead delay obtaining the `sys.exc_info()` object until `addError` is called. Fix #13497
1 parent 96f0319 commit c48c810

File tree

1 file changed

+32
-60
lines changed

1 file changed

+32
-60
lines changed

src/_pytest/unittest.py

Lines changed: 32 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import sys
1111
import traceback
1212
import types
13-
from typing import Any
1413
from typing import TYPE_CHECKING
1514
from typing import Union
1615

@@ -197,12 +196,6 @@ def unittest_setup_method_fixture(
197196
)
198197

199198

200-
# Name of the attribute in `twisted.python.Failure` instances that stores
201-
# the `sys.exc_info()` tuple.
202-
# See twisted.trial support in `pytest_runtest_protocol`.
203-
TWISTED_RAW_EXCINFO_ATTR = "_twisted_raw_excinfo"
204-
205-
206199
class TestCaseFunction(Function):
207200
nofuncargs = True
208201
_excinfo: list[_pytest._code.ExceptionInfo[BaseException]] | None = None
@@ -234,16 +227,7 @@ def startTest(self, testcase: unittest.TestCase) -> None:
234227
pass
235228

236229
def _addexcinfo(self, rawexcinfo: _SysExcInfoType) -> None:
237-
# Unwrap potential exception info (see twisted trial support below).
238-
# Twisted calls addError() passing its own classes (like `twisted.python.Failure`), which violates
239-
# the `addError()` signature, so we extract the original `sys.exc_info()` tuple which is stored
240-
# in the object.
241-
if hasattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR):
242-
saved_exc_info = getattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR)
243-
# Delete the attribute from the original object to avoid leaks.
244-
delattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR)
245-
rawexcinfo = saved_exc_info
246-
del saved_exc_info
230+
rawexcinfo = _handle_twisted_exc_info(rawexcinfo)
247231
try:
248232
excinfo = _pytest._code.ExceptionInfo[BaseException].from_exc_info(
249233
rawexcinfo # type: ignore[arg-type]
@@ -399,54 +383,42 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None:
399383
call.excinfo = call2.excinfo
400384

401385

402-
# Twisted trial support.
403-
classImplements_has_run = False
386+
def pytest_configure() -> None:
387+
"""Register the TestCaseFunction class as an IReporter if twisted.trial is available."""
388+
if _is_twisted_trial_available():
389+
from twisted.trial.itrial import IReporter
390+
from zope.interface import classImplements
404391

392+
classImplements(TestCaseFunction, IReporter)
405393

406-
@hookimpl(wrapper=True)
407-
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
408-
if isinstance(item, TestCaseFunction) and "twisted.trial.unittest" in sys.modules:
409-
ut: Any = sys.modules["twisted.python.failure"]
410-
global classImplements_has_run
411-
if not classImplements_has_run:
412-
from twisted.trial.itrial import IReporter
413-
from zope.interface import classImplements
414394

415-
classImplements(TestCaseFunction, IReporter)
416-
classImplements_has_run = True
395+
def _is_skipped(obj) -> bool:
396+
"""Return True if the given object has been marked with @unittest.skip."""
397+
return bool(getattr(obj, "__unittest_skip__", False))
417398

418-
# Monkeypatch `Failure.__init__` to store the raw exception info.
419-
Failure__init__ = ut.Failure.__init__
420399

421-
def store_raw_exception_info(
422-
self, exc_value=None, exc_type=None, exc_tb=None, captureVars=None
423-
):
424-
if exc_value is None:
425-
raw_exc_info = sys.exc_info()
426-
else:
427-
if exc_type is None:
428-
exc_type = type(exc_value)
429-
if exc_tb is None:
430-
exc_tb = sys.exc_info()[2]
431-
raw_exc_info = (exc_type, exc_value, exc_tb)
432-
setattr(self, TWISTED_RAW_EXCINFO_ATTR, tuple(raw_exc_info))
433-
try:
434-
Failure__init__(
435-
self, exc_value, exc_type, exc_tb, captureVars=captureVars
436-
)
437-
except TypeError:
438-
Failure__init__(self, exc_value, exc_type, exc_tb)
400+
def _is_twisted_trial_available() -> bool:
401+
return "twisted.trial.unittest" in sys.modules
439402

440-
ut.Failure.__init__ = store_raw_exception_info
441-
try:
442-
res = yield
443-
finally:
444-
ut.Failure.__init__ = Failure__init__
445-
else:
446-
res = yield
447-
return res
448403

404+
def _handle_twisted_exc_info(
405+
rawexcinfo: _SysExcInfoType | BaseException,
406+
) -> _SysExcInfoType:
407+
"""
408+
Twisted passes a custom Failure instance to `addError()` instead of using `sys.exc_info()`.
409+
Therefore, if `rawexcinfo` is a `Failure` instance, convert it into the equivalent `sys.exc_info()` tuple
410+
as expected by pytest.
411+
"""
412+
if isinstance(rawexcinfo, BaseException) and _is_twisted_trial_available():
413+
import twisted.python.failure
449414

450-
def _is_skipped(obj) -> bool:
451-
"""Return True if the given object has been marked with @unittest.skip."""
452-
return bool(getattr(obj, "__unittest_skip__", False))
415+
if isinstance(rawexcinfo, twisted.python.failure.Failure):
416+
tb = rawexcinfo.__traceback__
417+
if tb is None:
418+
tb = sys.exc_info()[2]
419+
return type(rawexcinfo.value), rawexcinfo.value, tb
420+
421+
# Unfortunately, because we cannot import `twisted.python.failure` at the top of the file
422+
# and use it in the signature, we need to use `type:ignore` here because we cannot narrow
423+
# the type properly in the `if` statement above.
424+
return rawexcinfo # type:ignore[return-value]

0 commit comments

Comments
 (0)