From f2d65c85f4e5761b0e763b04932df600aec5a480 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 13 Mar 2021 11:10:34 +0200 Subject: [PATCH] code: export ExceptionInfo for typing purposes This type is most prominent in `pytest.raises` and we should allow to refer to it by a public name. The type is not in a perfectly "exposable" state. In particular: - The `traceback` property with type `Traceback` which is derived from the `py.code` API and exposes a bunch more types transitively. This stuff is *not* exported and probably won't be. - The `getrepr` method which probably should be private. But they're already used in the wild so no point in just hiding them now. The __init__ API is hidden -- the public API for this are the `from_*` classmethods. --- changelog/7469.deprecation.rst | 1 + changelog/7469.feature.rst | 5 +++-- doc/en/how-to/assert.rst | 2 +- doc/en/reference/reference.rst | 2 +- src/_pytest/_code/code.py | 28 +++++++++++++++++++++------- src/_pytest/config/__init__.py | 2 +- src/_pytest/doctest.py | 2 +- src/_pytest/nodes.py | 2 +- src/_pytest/unittest.py | 2 +- src/pytest/__init__.py | 2 ++ testing/test_unittest.py | 26 +++++++++++++++++--------- 11 files changed, 50 insertions(+), 24 deletions(-) diff --git a/changelog/7469.deprecation.rst b/changelog/7469.deprecation.rst index 0d7908ef8..e01764caa 100644 --- a/changelog/7469.deprecation.rst +++ b/changelog/7469.deprecation.rst @@ -5,5 +5,6 @@ Directly constructing the following classes is now deprecated: - ``_pytest.mark.structures.MarkGenerator`` - ``_pytest.python.Metafunc`` - ``_pytest.runner.CallInfo`` +- ``_pytest._code.ExceptionInfo`` These have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 7.0.0. diff --git a/changelog/7469.feature.rst b/changelog/7469.feature.rst index f9948d686..ea8df5239 100644 --- a/changelog/7469.feature.rst +++ b/changelog/7469.feature.rst @@ -5,8 +5,9 @@ The newly-exported types are: - ``pytest.Mark`` for :class:`marks `. - ``pytest.MarkDecorator`` for :class:`mark decorators `. - ``pytest.MarkGenerator`` for the :class:`pytest.mark ` singleton. -- ``pytest.Metafunc`` for the :class:`metafunc ` argument to the `pytest_generate_tests ` hook. -- ``pytest.runner.CallInfo`` for the :class:`CallInfo ` type passed to various hooks. +- ``pytest.Metafunc`` for the :class:`metafunc ` argument to the :func:`pytest_generate_tests ` hook. +- ``pytest.CallInfo`` for the :class:`CallInfo ` type passed to various hooks. +- ``pytest.ExceptionInfo`` for the :class:`ExceptionInfo ` type returned from :func:`pytest.raises` and passed to various hooks. Constructing them directly is not supported; they are only meant for use in type annotations. Doing so will emit a deprecation warning, and may become a hard-error in pytest 7.0. diff --git a/doc/en/how-to/assert.rst b/doc/en/how-to/assert.rst index c0314f344..34b765b3b 100644 --- a/doc/en/how-to/assert.rst +++ b/doc/en/how-to/assert.rst @@ -98,7 +98,7 @@ and if you need to have access to the actual exception info you may use: f() assert "maximum recursion" in str(excinfo.value) -``excinfo`` is an ``ExceptionInfo`` instance, which is a wrapper around +``excinfo`` is an :class:`~pytest.ExceptionInfo` instance, which is a wrapper around the actual exception raised. The main attributes of interest are ``.type``, ``.value`` and ``.traceback``. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index d18150bac..d9986336c 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -793,7 +793,7 @@ Config ExceptionInfo ~~~~~~~~~~~~~ -.. autoclass:: _pytest._code.ExceptionInfo +.. autoclass:: pytest.ExceptionInfo() :members: diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 610f4e68d..1b4760a0c 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -42,6 +42,7 @@ from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr from _pytest.compat import final from _pytest.compat import get_real_func +from _pytest.deprecated import check_ispytest from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath @@ -440,15 +441,28 @@ E = TypeVar("E", bound=BaseException, covariant=True) @final -@attr.s(repr=False) +@attr.s(repr=False, init=False) class ExceptionInfo(Generic[E]): """Wraps sys.exc_info() objects and offers help for navigating the traceback.""" _assert_start_repr = "AssertionError('assert " _excinfo = attr.ib(type=Optional[Tuple[Type["E"], "E", TracebackType]]) - _striptext = attr.ib(type=str, default="") - _traceback = attr.ib(type=Optional[Traceback], default=None) + _striptext = attr.ib(type=str) + _traceback = attr.ib(type=Optional[Traceback]) + + def __init__( + self, + excinfo: Optional[Tuple[Type["E"], "E", TracebackType]], + striptext: str = "", + traceback: Optional[Traceback] = None, + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + self._excinfo = excinfo + self._striptext = striptext + self._traceback = traceback @classmethod def from_exc_info( @@ -475,7 +489,7 @@ class ExceptionInfo(Generic[E]): if exprinfo and exprinfo.startswith(cls._assert_start_repr): _striptext = "AssertionError: " - return cls(exc_info, _striptext) + return cls(exc_info, _striptext, _ispytest=True) @classmethod def from_current( @@ -502,7 +516,7 @@ class ExceptionInfo(Generic[E]): @classmethod def for_later(cls) -> "ExceptionInfo[E]": """Return an unfilled ExceptionInfo.""" - return cls(None) + return cls(None, _ispytest=True) def fill_unfilled(self, exc_info: Tuple[Type[E], E, TracebackType]) -> None: """Fill an unfilled ExceptionInfo created with ``for_later()``.""" @@ -922,7 +936,7 @@ class FormattedExcinfo: if e.__cause__ is not None and self.chain: e = e.__cause__ excinfo_ = ( - ExceptionInfo((type(e), e, e.__traceback__)) + ExceptionInfo.from_exc_info((type(e), e, e.__traceback__)) if e.__traceback__ else None ) @@ -932,7 +946,7 @@ class FormattedExcinfo: ): e = e.__context__ excinfo_ = ( - ExceptionInfo((type(e), e, e.__traceback__)) + ExceptionInfo.from_exc_info((type(e), e, e.__traceback__)) if e.__traceback__ else None ) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 3f138efa7..144f1c9d1 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -145,7 +145,7 @@ def main( try: config = _prepareconfig(args, plugins) except ConftestImportFailure as e: - exc_info = ExceptionInfo(e.excinfo) + exc_info = ExceptionInfo.from_exc_info(e.excinfo) tw = TerminalWriter(sys.stderr) tw.line(f"ImportError while loading conftest '{e.path}'.", red=True) exc_info.traceback = exc_info.traceback.filter( diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 41d295daa..b8e46297a 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -365,7 +365,7 @@ class DoctestItem(pytest.Item): example, failure.got, report_choice ).split("\n") else: - inner_excinfo = ExceptionInfo(failure.exc_info) + inner_excinfo = ExceptionInfo.from_exc_info(failure.exc_info) lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)] lines += [ x.strip("\n") diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 9d93659e1..0e23c7330 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -396,7 +396,7 @@ class Node(metaclass=NodeMeta): from _pytest.fixtures import FixtureLookupError if isinstance(excinfo.value, ConftestImportFailure): - excinfo = ExceptionInfo(excinfo.value.excinfo) + excinfo = ExceptionInfo.from_exc_info(excinfo.value.excinfo) if isinstance(excinfo.value, fail.Exception): if not excinfo.value.pytrace: style = "value" diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 3f88d7a9e..17fccc268 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -209,7 +209,7 @@ class TestCaseFunction(Function): # Unwrap potential exception info (see twisted trial support below). rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo) try: - excinfo = _pytest._code.ExceptionInfo(rawexcinfo) # type: ignore[arg-type] + excinfo = _pytest._code.ExceptionInfo[BaseException].from_exc_info(rawexcinfo) # type: ignore[arg-type] # Invoke the attributes to trigger storing the traceback # trial causes some issue there. excinfo.value diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 53917340f..02a82386d 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -2,6 +2,7 @@ """pytest: unit and functional testing with Python.""" from . import collect from _pytest import __version__ +from _pytest._code import ExceptionInfo from _pytest.assertion import register_assert_rewrite from _pytest.cacheprovider import Cache from _pytest.capture import CaptureFixture @@ -79,6 +80,7 @@ __all__ = [ "console_main", "deprecated_call", "exit", + "ExceptionInfo", "ExitCode", "fail", "File", diff --git a/testing/test_unittest.py b/testing/test_unittest.py index d7f773715..fd4c01d80 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -377,24 +377,32 @@ def test_testcase_adderrorandfailure_defers(pytester: Pytester, type: str) -> No def test_testcase_custom_exception_info(pytester: Pytester, type: str) -> None: pytester.makepyfile( """ + from typing import Generic, TypeVar from unittest import TestCase - import py, pytest - import _pytest._code + import pytest, _pytest._code + class MyTestCase(TestCase): def run(self, result): excinfo = pytest.raises(ZeroDivisionError, lambda: 0/0) - # we fake an incompatible exception info - from _pytest.monkeypatch import MonkeyPatch - mp = MonkeyPatch() - def t(*args): - mp.undo() - raise TypeError() - mp.setattr(_pytest._code, 'ExceptionInfo', t) + # We fake an incompatible exception info. + class FakeExceptionInfo(Generic[TypeVar("E")]): + def __init__(self, *args, **kwargs): + mp.undo() + raise TypeError() + @classmethod + def from_current(cls): + return cls() + @classmethod + def from_exc_info(cls, *args, **kwargs): + return cls() + mp = pytest.MonkeyPatch() + mp.setattr(_pytest._code, 'ExceptionInfo', FakeExceptionInfo) try: excinfo = excinfo._excinfo result.add%(type)s(self, excinfo) finally: mp.undo() + def test_hello(self): pass """