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.
This commit is contained in:
Ran Benita 2021-03-13 11:10:34 +02:00
parent 96ef7d678b
commit f2d65c85f4
11 changed files with 50 additions and 24 deletions

View File

@ -5,5 +5,6 @@ Directly constructing the following classes is now deprecated:
- ``_pytest.mark.structures.MarkGenerator`` - ``_pytest.mark.structures.MarkGenerator``
- ``_pytest.python.Metafunc`` - ``_pytest.python.Metafunc``
- ``_pytest.runner.CallInfo`` - ``_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. These have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 7.0.0.

View File

@ -5,8 +5,9 @@ The newly-exported types are:
- ``pytest.Mark`` for :class:`marks <pytest.Mark>`. - ``pytest.Mark`` for :class:`marks <pytest.Mark>`.
- ``pytest.MarkDecorator`` for :class:`mark decorators <pytest.MarkDecorator>`. - ``pytest.MarkDecorator`` for :class:`mark decorators <pytest.MarkDecorator>`.
- ``pytest.MarkGenerator`` for the :class:`pytest.mark <pytest.MarkGenerator>` singleton. - ``pytest.MarkGenerator`` for the :class:`pytest.mark <pytest.MarkGenerator>` singleton.
- ``pytest.Metafunc`` for the :class:`metafunc <pytest.MarkGenerator>` argument to the `pytest_generate_tests <pytest.hookspec.pytest_generate_tests>` hook. - ``pytest.Metafunc`` for the :class:`metafunc <pytest.MarkGenerator>` argument to the :func:`pytest_generate_tests <pytest.hookspec.pytest_generate_tests>` hook.
- ``pytest.runner.CallInfo`` for the :class:`CallInfo <pytest.CallInfo>` type passed to various hooks. - ``pytest.CallInfo`` for the :class:`CallInfo <pytest.CallInfo>` type passed to various hooks.
- ``pytest.ExceptionInfo`` for the :class:`ExceptionInfo <pytest.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. 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. Doing so will emit a deprecation warning, and may become a hard-error in pytest 7.0.

View File

@ -98,7 +98,7 @@ and if you need to have access to the actual exception info you may use:
f() f()
assert "maximum recursion" in str(excinfo.value) 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 the actual exception raised. The main attributes of interest are
``.type``, ``.value`` and ``.traceback``. ``.type``, ``.value`` and ``.traceback``.

View File

@ -793,7 +793,7 @@ Config
ExceptionInfo ExceptionInfo
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
.. autoclass:: _pytest._code.ExceptionInfo .. autoclass:: pytest.ExceptionInfo()
:members: :members:

View File

@ -42,6 +42,7 @@ from _pytest._io.saferepr import safeformat
from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr
from _pytest.compat import final from _pytest.compat import final
from _pytest.compat import get_real_func from _pytest.compat import get_real_func
from _pytest.deprecated import check_ispytest
from _pytest.pathlib import absolutepath from _pytest.pathlib import absolutepath
from _pytest.pathlib import bestrelpath from _pytest.pathlib import bestrelpath
@ -440,15 +441,28 @@ E = TypeVar("E", bound=BaseException, covariant=True)
@final @final
@attr.s(repr=False) @attr.s(repr=False, init=False)
class ExceptionInfo(Generic[E]): class ExceptionInfo(Generic[E]):
"""Wraps sys.exc_info() objects and offers help for navigating the traceback.""" """Wraps sys.exc_info() objects and offers help for navigating the traceback."""
_assert_start_repr = "AssertionError('assert " _assert_start_repr = "AssertionError('assert "
_excinfo = attr.ib(type=Optional[Tuple[Type["E"], "E", TracebackType]]) _excinfo = attr.ib(type=Optional[Tuple[Type["E"], "E", TracebackType]])
_striptext = attr.ib(type=str, default="") _striptext = attr.ib(type=str)
_traceback = attr.ib(type=Optional[Traceback], default=None) _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 @classmethod
def from_exc_info( def from_exc_info(
@ -475,7 +489,7 @@ class ExceptionInfo(Generic[E]):
if exprinfo and exprinfo.startswith(cls._assert_start_repr): if exprinfo and exprinfo.startswith(cls._assert_start_repr):
_striptext = "AssertionError: " _striptext = "AssertionError: "
return cls(exc_info, _striptext) return cls(exc_info, _striptext, _ispytest=True)
@classmethod @classmethod
def from_current( def from_current(
@ -502,7 +516,7 @@ class ExceptionInfo(Generic[E]):
@classmethod @classmethod
def for_later(cls) -> "ExceptionInfo[E]": def for_later(cls) -> "ExceptionInfo[E]":
"""Return an unfilled ExceptionInfo.""" """Return an unfilled ExceptionInfo."""
return cls(None) return cls(None, _ispytest=True)
def fill_unfilled(self, exc_info: Tuple[Type[E], E, TracebackType]) -> None: def fill_unfilled(self, exc_info: Tuple[Type[E], E, TracebackType]) -> None:
"""Fill an unfilled ExceptionInfo created with ``for_later()``.""" """Fill an unfilled ExceptionInfo created with ``for_later()``."""
@ -922,7 +936,7 @@ class FormattedExcinfo:
if e.__cause__ is not None and self.chain: if e.__cause__ is not None and self.chain:
e = e.__cause__ e = e.__cause__
excinfo_ = ( excinfo_ = (
ExceptionInfo((type(e), e, e.__traceback__)) ExceptionInfo.from_exc_info((type(e), e, e.__traceback__))
if e.__traceback__ if e.__traceback__
else None else None
) )
@ -932,7 +946,7 @@ class FormattedExcinfo:
): ):
e = e.__context__ e = e.__context__
excinfo_ = ( excinfo_ = (
ExceptionInfo((type(e), e, e.__traceback__)) ExceptionInfo.from_exc_info((type(e), e, e.__traceback__))
if e.__traceback__ if e.__traceback__
else None else None
) )

View File

@ -145,7 +145,7 @@ def main(
try: try:
config = _prepareconfig(args, plugins) config = _prepareconfig(args, plugins)
except ConftestImportFailure as e: except ConftestImportFailure as e:
exc_info = ExceptionInfo(e.excinfo) exc_info = ExceptionInfo.from_exc_info(e.excinfo)
tw = TerminalWriter(sys.stderr) tw = TerminalWriter(sys.stderr)
tw.line(f"ImportError while loading conftest '{e.path}'.", red=True) tw.line(f"ImportError while loading conftest '{e.path}'.", red=True)
exc_info.traceback = exc_info.traceback.filter( exc_info.traceback = exc_info.traceback.filter(

View File

@ -365,7 +365,7 @@ class DoctestItem(pytest.Item):
example, failure.got, report_choice example, failure.got, report_choice
).split("\n") ).split("\n")
else: 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 += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)]
lines += [ lines += [
x.strip("\n") x.strip("\n")

View File

@ -396,7 +396,7 @@ class Node(metaclass=NodeMeta):
from _pytest.fixtures import FixtureLookupError from _pytest.fixtures import FixtureLookupError
if isinstance(excinfo.value, ConftestImportFailure): 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 isinstance(excinfo.value, fail.Exception):
if not excinfo.value.pytrace: if not excinfo.value.pytrace:
style = "value" style = "value"

View File

@ -209,7 +209,7 @@ class TestCaseFunction(Function):
# Unwrap potential exception info (see twisted trial support below). # Unwrap potential exception info (see twisted trial support below).
rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo) rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo)
try: 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 # Invoke the attributes to trigger storing the traceback
# trial causes some issue there. # trial causes some issue there.
excinfo.value excinfo.value

View File

@ -2,6 +2,7 @@
"""pytest: unit and functional testing with Python.""" """pytest: unit and functional testing with Python."""
from . import collect from . import collect
from _pytest import __version__ from _pytest import __version__
from _pytest._code import ExceptionInfo
from _pytest.assertion import register_assert_rewrite from _pytest.assertion import register_assert_rewrite
from _pytest.cacheprovider import Cache from _pytest.cacheprovider import Cache
from _pytest.capture import CaptureFixture from _pytest.capture import CaptureFixture
@ -79,6 +80,7 @@ __all__ = [
"console_main", "console_main",
"deprecated_call", "deprecated_call",
"exit", "exit",
"ExceptionInfo",
"ExitCode", "ExitCode",
"fail", "fail",
"File", "File",

View File

@ -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: def test_testcase_custom_exception_info(pytester: Pytester, type: str) -> None:
pytester.makepyfile( pytester.makepyfile(
""" """
from typing import Generic, TypeVar
from unittest import TestCase from unittest import TestCase
import py, pytest import pytest, _pytest._code
import _pytest._code
class MyTestCase(TestCase): class MyTestCase(TestCase):
def run(self, result): def run(self, result):
excinfo = pytest.raises(ZeroDivisionError, lambda: 0/0) excinfo = pytest.raises(ZeroDivisionError, lambda: 0/0)
# we fake an incompatible exception info # We fake an incompatible exception info.
from _pytest.monkeypatch import MonkeyPatch class FakeExceptionInfo(Generic[TypeVar("E")]):
mp = MonkeyPatch() def __init__(self, *args, **kwargs):
def t(*args):
mp.undo() mp.undo()
raise TypeError() raise TypeError()
mp.setattr(_pytest._code, 'ExceptionInfo', t) @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: try:
excinfo = excinfo._excinfo excinfo = excinfo._excinfo
result.add%(type)s(self, excinfo) result.add%(type)s(self, excinfo)
finally: finally:
mp.undo() mp.undo()
def test_hello(self): def test_hello(self):
pass pass
""" """