Repository: pytest-dev/pluggy
Pull request #388 (2023-06-11 20:26:58)

result: use exception instead excinfo triplet
The excinfo triplet is a legacy concept, these days the exception instance itself carries the necessary info.

Switch the internal implementation to store just the exception instead of the triplet, and add an `exception` property to access it without the excinfo annoyance.


Patch:
diff --git a/docs/index.rst b/docs/index.rst
index 58e33f3..666baf8 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -775,7 +775,7 @@ hook invocation point:
             outcome.get_result()
         except RuntimeError:
             # log the error details
-            print(outcome.excinfo)
+            print(outcome.exception)
 
 
     pm = PluginManager("myproject")
diff --git a/src/pluggy/_callers.py b/src/pluggy/_callers.py
index 73fd04e..ef6cfbe 100644
--- a/src/pluggy/_callers.py
+++ b/src/pluggy/_callers.py
@@ -1,7 +1,6 @@
 """
 Call loop machinery
 """
-import sys
 from typing import cast
 from typing import Generator
 from typing import List
@@ -31,7 +30,7 @@ def _multicall(
     """
     __tracebackhide__ = True
     results: List[object] = []
-    excinfo = None
+    exception = None
     try:  # run impl and wrapper setup functions in a loop
         teardowns = []
         try:
@@ -61,19 +60,15 @@ def _multicall(
                         results.append(res)
                         if firstresult:  # halt further impl calls
                             break
-        except BaseException:
-            _excinfo = sys.exc_info()
-            assert _excinfo[0] is not None
-            assert _excinfo[1] is not None
-            assert _excinfo[2] is not None
-            excinfo = (_excinfo[0], _excinfo[1], _excinfo[2])
+        except BaseException as exc:
+            exception = exc
     finally:
         if firstresult:  # first result hooks return a single value
             outcome: _Result[Union[object, List[object]]] = _Result(
-                results[0] if results else None, excinfo
+                results[0] if results else None, exception
             )
         else:
-            outcome = _Result(results, excinfo)
+            outcome = _Result(results, exception)
 
         # run all wrapper post-yield blocks
         for gen in reversed(teardowns):
diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py
index bb8c581..2e0304c 100644
--- a/src/pluggy/_manager.py
+++ b/src/pluggy/_manager.py
@@ -452,7 +452,7 @@ class PluginManager:
             methods: Sequence[HookImpl],
             kwargs: Mapping[str, object],
         ) -> None:
-            if outcome.excinfo is None:
+            if outcome.exception is None:
                 hooktrace("finish", hook_name, "-->", outcome.get_result())
             hooktrace.root.indent -= 1
 
diff --git a/src/pluggy/_result.py b/src/pluggy/_result.py
index b603ab0..79866d9 100644
--- a/src/pluggy/_result.py
+++ b/src/pluggy/_result.py
@@ -1,7 +1,6 @@
 """
 Hook wrapper "result" utilities.
 """
-import sys
 from types import TracebackType
 from typing import Callable
 from typing import cast
@@ -17,7 +16,7 @@ if TYPE_CHECKING:
     from typing import NoReturn
 
 
-_ExcInfo = Tuple[Type[BaseException], BaseException, TracebackType]
+_ExcInfo = Tuple[Type[BaseException], BaseException, Optional[TracebackType]]
 _T = TypeVar("_T")
 
 
@@ -36,30 +35,37 @@ class HookCallError(Exception):
 
 
 class _Result(Generic[_T]):
-    __slots__ = ("_result", "_excinfo")
+    __slots__ = ("_result", "_exception")
 
-    def __init__(self, result: Optional[_T], excinfo: Optional[_ExcInfo]) -> None:
+    def __init__(
+        self,
+        result: Optional[_T],
+        exception: Optional[BaseException],
+    ) -> None:
         self._result = result
-        self._excinfo = excinfo
+        self._exception = exception
 
     @property
     def excinfo(self) -> Optional[_ExcInfo]:
-        return self._excinfo
+        exc = self._exception
+        if exc is None:
+            return None
+        else:
+            return (type(exc), exc, exc.__traceback__)
+
+    @property
+    def exception(self) -> Optional[BaseException]:
+        return self._exception
 
     @classmethod
     def from_call(cls, func: Callable[[], _T]) -> "_Result[_T]":
         __tracebackhide__ = True
-        result = excinfo = None
+        result = exception = None
         try:
             result = func()
-        except BaseException:
-            _excinfo = sys.exc_info()
-            assert _excinfo[0] is not None
-            assert _excinfo[1] is not None
-            assert _excinfo[2] is not None
-            excinfo = (_excinfo[0], _excinfo[1], _excinfo[2])
-
-        return cls(result, excinfo)
+        except BaseException as exc:
+            exception = exc
+        return cls(result, exception)
 
     def force_result(self, result: _T) -> None:
         """Force the result(s) to ``result``.
@@ -69,7 +75,7 @@ class _Result(Generic[_T]):
         found during invocation will be deleted.
         """
         self._result = result
-        self._excinfo = None
+        self._exception = None
 
     def get_result(self) -> _T:
         """Get the result(s) for this hook call.
@@ -78,8 +84,8 @@ class _Result(Generic[_T]):
         will be returned, otherwise a list of results.
         """
         __tracebackhide__ = True
-        if self._excinfo is None:
+        exc = self._exception
+        if exc is None:
             return cast(_T, self._result)
         else:
-            ex = self._excinfo
-            raise ex[1].with_traceback(ex[2])
+            raise exc.with_traceback(exc.__traceback__)


Test patch:
diff --git a/testing/test_invocations.py b/testing/test_invocations.py
index 43fdbff..707810d 100644
--- a/testing/test_invocations.py
+++ b/testing/test_invocations.py
@@ -100,6 +100,8 @@ def test_call_order(pm: PluginManager) -> None:
             assert arg == 0
             outcome = yield
             assert outcome.get_result() == [3, 2, 1]
+            assert outcome.exception is None
+            assert outcome.excinfo is None
 
     pm.register(Plugin1())
     pm.register(Plugin2())
diff --git a/testing/test_multicall.py b/testing/test_multicall.py
index 8787057..f9f2a2c 100644
--- a/testing/test_multicall.py
+++ b/testing/test_multicall.py
@@ -149,7 +149,9 @@ def test_hookwrapper_exception(exc: "Type[BaseException]") -> None:
     @hookimpl(hookwrapper=True)
     def m1():
         out.append("m1 init")
-        yield None
+        result = yield
+        assert isinstance(result.exception, exc)
+        assert result.excinfo[0] == exc
         out.append("m1 finish")
 
     @hookimpl