python3.14-pytest/13502.patch
2025-11-28 11:03:04 +01:00

307 lines
12 KiB
Diff

From d22f91b24b724bbf6011caf770b9611cf33789c8 Mon Sep 17 00:00:00 2001
From: Bruno Oliveira <bruno@pytest.org>
Date: Mon, 9 Jun 2025 19:22:40 -0300
Subject: [PATCH] Fix compatibility with Twisted 25
As discussed in https://github.com/pytest-dev/pytest/pull/13502, the fix for compatibility with Twisted 25+ is simpler. Therefore, it makes sense to implement both fixes (for Twisted 24 and Twisted 25) in parallel. This way, we can eventually drop support for Twisted <25 and keep only the simpler workaround.
In addition, the `unittestextras` tox environment has been replaced with dedicated test environments for `asynctest`, `Twisted 24`, and `Twisted 25`.
Fixes #13497
---
changelog/13497.bugfix.rst | 1 +
src/_pytest/unittest.py | 187 ++++++++++++++++++++++++++-----------
tox.ini | 17 +++-
3 files changed, 148 insertions(+), 57 deletions(-)
create mode 100644 changelog/13497.bugfix.rst
diff --git a/changelog/13497.bugfix.rst b/changelog/13497.bugfix.rst
new file mode 100644
index 0000000..75b4996
--- /dev/null
+++ b/changelog/13497.bugfix.rst
@@ -0,0 +1 @@
+Fixed compatibility with ``Twisted 25+``.
diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py
index 8cecd4f..ef6ef64 100644
--- a/src/_pytest/unittest.py
+++ b/src/_pytest/unittest.py
@@ -3,16 +3,16 @@
from __future__ import annotations
+from collections.abc import Callable
+from collections.abc import Generator
+from collections.abc import Iterable
+from collections.abc import Iterator
+from enum import auto
+from enum import Enum
import inspect
import sys
import traceback
import types
-from typing import Any
-from typing import Callable
-from typing import Generator
-from typing import Iterable
-from typing import Tuple
-from typing import Type
from typing import TYPE_CHECKING
from typing import Union
@@ -20,6 +20,7 @@ import _pytest._code
from _pytest.compat import is_async_function
from _pytest.config import hookimpl
from _pytest.fixtures import FixtureRequest
+from _pytest.monkeypatch import MonkeyPatch
from _pytest.nodes import Collector
from _pytest.nodes import Item
from _pytest.outcomes import exit
@@ -43,8 +44,8 @@ if TYPE_CHECKING:
_SysExcInfoType = Union[
- Tuple[Type[BaseException], BaseException, types.TracebackType],
- Tuple[None, None, None],
+ tuple[type[BaseException], BaseException, types.TracebackType],
+ tuple[None, None, None],
]
@@ -230,8 +231,7 @@ class TestCaseFunction(Function):
pass
def _addexcinfo(self, rawexcinfo: _SysExcInfoType) -> None:
- # Unwrap potential exception info (see twisted trial support below).
- rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo)
+ rawexcinfo = _handle_twisted_exc_info(rawexcinfo)
try:
excinfo = _pytest._code.ExceptionInfo[BaseException].from_exc_info(
rawexcinfo # type: ignore[arg-type]
@@ -387,49 +387,130 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None:
call.excinfo = call2.excinfo
-# Twisted trial support.
-classImplements_has_run = False
-
-
-@hookimpl(wrapper=True)
-def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
- if isinstance(item, TestCaseFunction) and "twisted.trial.unittest" in sys.modules:
- ut: Any = sys.modules["twisted.python.failure"]
- global classImplements_has_run
- Failure__init__ = ut.Failure.__init__
- if not classImplements_has_run:
- from twisted.trial.itrial import IReporter
- from zope.interface import classImplements
-
- classImplements(TestCaseFunction, IReporter)
- classImplements_has_run = True
-
- def excstore(
- self, exc_value=None, exc_type=None, exc_tb=None, captureVars=None
- ):
- if exc_value is None:
- self._rawexcinfo = sys.exc_info()
- else:
- if exc_type is None:
- exc_type = type(exc_value)
- self._rawexcinfo = (exc_type, exc_value, exc_tb)
- try:
- Failure__init__(
- self, exc_value, exc_type, exc_tb, captureVars=captureVars
- )
- except TypeError:
- Failure__init__(self, exc_value, exc_type, exc_tb)
-
- ut.Failure.__init__ = excstore
- try:
- res = yield
- finally:
- ut.Failure.__init__ = Failure__init__
- else:
- res = yield
- return res
-
-
def _is_skipped(obj) -> bool:
"""Return True if the given object has been marked with @unittest.skip."""
return bool(getattr(obj, "__unittest_skip__", False))
+
+
+def pytest_configure() -> None:
+ """Register the TestCaseFunction class as an IReporter if twisted.trial is available."""
+ if _get_twisted_version() is not TwistedVersion.NotInstalled:
+ from twisted.trial.itrial import IReporter
+ from zope.interface import classImplements
+
+ classImplements(TestCaseFunction, IReporter)
+
+
+class TwistedVersion(Enum):
+ """
+ The Twisted version installed in the environment.
+
+ We have different workarounds in place for different versions of Twisted.
+ """
+
+ # Twisted version 24 or prior.
+ Version24 = auto()
+ # Twisted version 25 or later.
+ Version25 = auto()
+ # Twisted version is not available.
+ NotInstalled = auto()
+
+
+def _get_twisted_version() -> TwistedVersion:
+ # We need to check if "twisted.trial.unittest" is specifically present in sys.modules.
+ # This is because we intend to integrate with Trial only when it's actively running
+ # the test suite, but not needed when only other Twisted components are in use.
+ if "twisted.trial.unittest" not in sys.modules:
+ return TwistedVersion.NotInstalled
+
+ import importlib.metadata
+
+ import packaging.version
+
+ version_str = importlib.metadata.version("twisted")
+ version = packaging.version.parse(version_str)
+ if version.major <= 24:
+ return TwistedVersion.Version24
+ else:
+ return TwistedVersion.Version25
+
+
+# Name of the attribute in `twisted.python.Failure` instances that stores
+# the `sys.exc_info()` tuple.
+# See twisted.trial support in `pytest_runtest_protocol`.
+TWISTED_RAW_EXCINFO_ATTR = "_twisted_raw_excinfo"
+
+
+@hookimpl(wrapper=True)
+def pytest_runtest_protocol(item: Item) -> Iterator[None]:
+ if _get_twisted_version() is TwistedVersion.Version24:
+ import twisted.python.failure as ut
+
+ # Monkeypatch `Failure.__init__` to store the raw exception info.
+ original__init__ = ut.Failure.__init__
+
+ def store_raw_exception_info(
+ self, exc_value=None, exc_type=None, exc_tb=None, captureVars=None
+ ): # pragma: no cover
+ if exc_value is None:
+ raw_exc_info = sys.exc_info()
+ else:
+ if exc_type is None:
+ exc_type = type(exc_value)
+ if exc_tb is None:
+ exc_tb = sys.exc_info()[2]
+ raw_exc_info = (exc_type, exc_value, exc_tb)
+ setattr(self, TWISTED_RAW_EXCINFO_ATTR, tuple(raw_exc_info))
+ try:
+ original__init__(
+ self, exc_value, exc_type, exc_tb, captureVars=captureVars
+ )
+ except TypeError: # pragma: no cover
+ original__init__(self, exc_value, exc_type, exc_tb)
+
+ with MonkeyPatch.context() as patcher:
+ patcher.setattr(ut.Failure, "__init__", store_raw_exception_info)
+ return (yield)
+ else:
+ return (yield)
+
+
+def _handle_twisted_exc_info(
+ rawexcinfo: _SysExcInfoType | BaseException,
+) -> _SysExcInfoType:
+ """
+ Twisted passes a custom Failure instance to `addError()` instead of using `sys.exc_info()`.
+ Therefore, if `rawexcinfo` is a `Failure` instance, convert it into the equivalent `sys.exc_info()` tuple
+ as expected by pytest.
+ """
+ twisted_version = _get_twisted_version()
+ if twisted_version is TwistedVersion.NotInstalled:
+ # Unfortunately, because we cannot import `twisted.python.failure` at the top of the file
+ # and use it in the signature, we need to use `type:ignore` here because we cannot narrow
+ # the type properly in the `if` statement above.
+ return rawexcinfo # type:ignore[return-value]
+ elif twisted_version is TwistedVersion.Version24:
+ # Twisted calls addError() passing its own classes (like `twisted.python.Failure`), which violates
+ # the `addError()` signature, so we extract the original `sys.exc_info()` tuple which is stored
+ # in the object.
+ if hasattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR):
+ saved_exc_info = getattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR)
+ # Delete the attribute from the original object to avoid leaks.
+ delattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR)
+ return saved_exc_info # type:ignore[no-any-return]
+ return rawexcinfo # type:ignore[return-value]
+ elif twisted_version is TwistedVersion.Version25:
+ if isinstance(rawexcinfo, BaseException):
+ import twisted.python.failure
+
+ if isinstance(rawexcinfo, twisted.python.failure.Failure):
+ tb = rawexcinfo.__traceback__
+ if tb is None:
+ tb = sys.exc_info()[2]
+ return type(rawexcinfo.value), rawexcinfo.value, tb
+
+ return rawexcinfo # type:ignore[return-value]
+ else:
+ # Ideally we would use assert_never() here, but it is not available in all Python versions
+ # we support, plus we do not require `type_extensions` currently.
+ assert False, f"Unexpected Twisted version: {twisted_version}"
diff --git a/tox.ini b/tox.ini
index 6f49b80..ac47f6f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -37,7 +37,9 @@ description =
pexpect: against `pexpect`
pluggymain: against the bleeding edge `pluggy` from Git
pylib: against `py` lib
- unittestextras: against the unit test extras
+ twisted24: against the unit test extras with twisted prior to 24.0
+ twisted25: against the unit test extras with twisted 25.0 or later
+ asynctest: against the unit test extras with asynctest
xdist: with pytest in parallel mode
under `{basepython}`
doctesting: including doctests
@@ -52,7 +54,7 @@ passenv =
TERM
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST
setenv =
- _PYTEST_TOX_DEFAULT_POSARGS={env:_PYTEST_TOX_POSARGS_DOCTESTING:} {env:_PYTEST_TOX_POSARGS_LSOF:} {env:_PYTEST_TOX_POSARGS_XDIST:}
+ _PYTEST_TOX_DEFAULT_POSARGS={env:_PYTEST_TOX_POSARGS_DOCTESTING:} {env:_PYTEST_TOX_POSARGS_LSOF:} {env:_PYTEST_TOX_POSARGS_XDIST:} {env:_PYTEST_FILES:}
# See https://docs.python.org/3/library/io.html#io-encoding-warning
# If we don't enable this, neither can any of our downstream users!
@@ -67,6 +69,12 @@ setenv =
doctesting: _PYTEST_TOX_POSARGS_DOCTESTING=doc/en
+ # The configurations below are related only to standard unittest support.
+ # Run only tests from test_unittest.py.
+ asynctest: _PYTEST_FILES=testing/test_unittest.py
+ twisted24: _PYTEST_FILES=testing/test_unittest.py
+ twisted25: _PYTEST_FILES=testing/test_unittest.py
+
nobyte: PYTHONDONTWRITEBYTECODE=1
lsof: _PYTEST_TOX_POSARGS_LSOF=--lsof
@@ -80,8 +88,9 @@ deps =
pexpect: pexpect>=4.8.0
pluggymain: pluggy @ git+https://github.com/pytest-dev/pluggy.git
pylib: py>=1.8.2
- unittestextras: twisted
- unittestextras: asynctest
+ twisted24: twisted<25
+ twisted25: twisted>=25
+ asynctest: asynctest
xdist: pytest-xdist>=2.1.0
xdist: -e .
{env:_PYTEST_TOX_EXTRA_DEP:}
--
2.50.1