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 331aaabc7..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 @@ -436,26 +437,39 @@ co_equal = compile( ) -_E = TypeVar("_E", bound=BaseException, covariant=True) +E = TypeVar("E", bound=BaseException, covariant=True) @final -@attr.s(repr=False) -class ExceptionInfo(Generic[_E]): +@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) + _excinfo = attr.ib(type=Optional[Tuple[Type["E"], "E", TracebackType]]) + _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( cls, - exc_info: Tuple[Type[_E], _E, TracebackType], + exc_info: Tuple[Type[E], E, TracebackType], exprinfo: Optional[str] = None, - ) -> "ExceptionInfo[_E]": + ) -> "ExceptionInfo[E]": """Return an ExceptionInfo for an existing exc_info tuple. .. warning:: @@ -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( @@ -500,17 +514,17 @@ class ExceptionInfo(Generic[_E]): return ExceptionInfo.from_exc_info(exc_info, exprinfo) @classmethod - def for_later(cls) -> "ExceptionInfo[_E]": + 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: + def fill_unfilled(self, exc_info: Tuple[Type[E], E, TracebackType]) -> None: """Fill an unfilled ExceptionInfo created with ``for_later()``.""" assert self._excinfo is None, "ExceptionInfo was already filled" self._excinfo = exc_info @property - def type(self) -> Type[_E]: + def type(self) -> Type[E]: """The exception class.""" assert ( self._excinfo is not None @@ -518,7 +532,7 @@ class ExceptionInfo(Generic[_E]): return self._excinfo[0] @property - def value(self) -> _E: + def value(self) -> E: """The exception value.""" assert ( self._excinfo is not None @@ -562,10 +576,10 @@ class ExceptionInfo(Generic[_E]): def exconly(self, tryshort: bool = False) -> str: """Return the exception as a string. - When 'tryshort' resolves to True, and the exception is a - _pytest._code._AssertionError, only the actual exception part of - the exception representation is returned (so 'AssertionError: ' is - removed from the beginning). + When 'tryshort' resolves to True, and the exception is an + AssertionError, only the actual exception part of the exception + representation is returned (so 'AssertionError: ' is removed from + the beginning). """ lines = format_exception_only(self.type, self.value) text = "".join(lines) @@ -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/fixtures.py b/src/_pytest/fixtures.py index 722400ff7..5ff8ba3ca 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -79,18 +79,18 @@ if TYPE_CHECKING: # The value of the fixture -- return/yield of the fixture function (type variable). -_FixtureValue = TypeVar("_FixtureValue") +FixtureValue = TypeVar("FixtureValue") # The type of the fixture function (type variable). -_FixtureFunction = TypeVar("_FixtureFunction", bound=Callable[..., object]) +FixtureFunction = TypeVar("FixtureFunction", bound=Callable[..., object]) # The type of a fixture function (type alias generic in fixture value). _FixtureFunc = Union[ - Callable[..., _FixtureValue], Callable[..., Generator[_FixtureValue, None, None]] + Callable[..., FixtureValue], Callable[..., Generator[FixtureValue, None, None]] ] # The type of FixtureDef.cached_result (type alias generic in fixture value). _FixtureCachedResult = Union[ Tuple[ # The result. - _FixtureValue, + FixtureValue, # Cache key. object, None, @@ -106,8 +106,8 @@ _FixtureCachedResult = Union[ @attr.s(frozen=True) -class PseudoFixtureDef(Generic[_FixtureValue]): - cached_result = attr.ib(type="_FixtureCachedResult[_FixtureValue]") +class PseudoFixtureDef(Generic[FixtureValue]): + cached_result = attr.ib(type="_FixtureCachedResult[FixtureValue]") scope = attr.ib(type="_Scope") @@ -928,11 +928,11 @@ def fail_fixturefunc(fixturefunc, msg: str) -> "NoReturn": def call_fixture_func( - fixturefunc: "_FixtureFunc[_FixtureValue]", request: FixtureRequest, kwargs -) -> _FixtureValue: + fixturefunc: "_FixtureFunc[FixtureValue]", request: FixtureRequest, kwargs +) -> FixtureValue: if is_generator(fixturefunc): fixturefunc = cast( - Callable[..., Generator[_FixtureValue, None, None]], fixturefunc + Callable[..., Generator[FixtureValue, None, None]], fixturefunc ) generator = fixturefunc(**kwargs) try: @@ -942,7 +942,7 @@ def call_fixture_func( finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator) request.addfinalizer(finalizer) else: - fixturefunc = cast(Callable[..., _FixtureValue], fixturefunc) + fixturefunc = cast(Callable[..., FixtureValue], fixturefunc) fixture_result = fixturefunc(**kwargs) return fixture_result @@ -985,7 +985,7 @@ def _eval_scope_callable( @final -class FixtureDef(Generic[_FixtureValue]): +class FixtureDef(Generic[FixtureValue]): """A container for a factory definition.""" def __init__( @@ -993,7 +993,7 @@ class FixtureDef(Generic[_FixtureValue]): fixturemanager: "FixtureManager", baseid: Optional[str], argname: str, - func: "_FixtureFunc[_FixtureValue]", + func: "_FixtureFunc[FixtureValue]", scope: "Union[_Scope, Callable[[str, Config], _Scope]]", params: Optional[Sequence[object]], unittest: bool = False, @@ -1026,7 +1026,7 @@ class FixtureDef(Generic[_FixtureValue]): ) self.unittest = unittest self.ids = ids - self.cached_result: Optional[_FixtureCachedResult[_FixtureValue]] = None + self.cached_result: Optional[_FixtureCachedResult[FixtureValue]] = None self._finalizers: List[Callable[[], object]] = [] def addfinalizer(self, finalizer: Callable[[], object]) -> None: @@ -1055,7 +1055,7 @@ class FixtureDef(Generic[_FixtureValue]): self.cached_result = None self._finalizers = [] - def execute(self, request: SubRequest) -> _FixtureValue: + def execute(self, request: SubRequest) -> FixtureValue: # Get required arguments and register our own finish() # with their finalization. for argname in self.argnames: @@ -1096,8 +1096,8 @@ class FixtureDef(Generic[_FixtureValue]): def resolve_fixture_function( - fixturedef: FixtureDef[_FixtureValue], request: FixtureRequest -) -> "_FixtureFunc[_FixtureValue]": + fixturedef: FixtureDef[FixtureValue], request: FixtureRequest +) -> "_FixtureFunc[FixtureValue]": """Get the actual callable that can be called to obtain the fixture value, dealing with unittest-specific instances and bound methods.""" fixturefunc = fixturedef.func @@ -1123,8 +1123,8 @@ def resolve_fixture_function( def pytest_fixture_setup( - fixturedef: FixtureDef[_FixtureValue], request: SubRequest -) -> _FixtureValue: + fixturedef: FixtureDef[FixtureValue], request: SubRequest +) -> FixtureValue: """Execution of fixture setup.""" kwargs = {} for argname in fixturedef.argnames: @@ -1174,9 +1174,9 @@ def _params_converter( def wrap_function_to_error_out_if_called_directly( - function: _FixtureFunction, + function: FixtureFunction, fixture_marker: "FixtureFunctionMarker", -) -> _FixtureFunction: +) -> FixtureFunction: """Wrap the given fixture function so we can raise an error about it being called directly, instead of used as an argument in a test function.""" message = ( @@ -1194,7 +1194,7 @@ def wrap_function_to_error_out_if_called_directly( # further than this point and lose useful wrappings like @mock.patch (#3774). result.__pytest_wrapped__ = _PytestWrapper(function) # type: ignore[attr-defined] - return cast(_FixtureFunction, result) + return cast(FixtureFunction, result) @final @@ -1213,7 +1213,7 @@ class FixtureFunctionMarker: ) name = attr.ib(type=Optional[str], default=None) - def __call__(self, function: _FixtureFunction) -> _FixtureFunction: + def __call__(self, function: FixtureFunction) -> FixtureFunction: if inspect.isclass(function): raise ValueError("class fixtures not supported (maybe in the future)") @@ -1241,7 +1241,7 @@ class FixtureFunctionMarker: @overload def fixture( - fixture_function: _FixtureFunction, + fixture_function: FixtureFunction, *, scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = ..., params: Optional[Iterable[object]] = ..., @@ -1253,7 +1253,7 @@ def fixture( ] ] = ..., name: Optional[str] = ..., -) -> _FixtureFunction: +) -> FixtureFunction: ... @@ -1276,7 +1276,7 @@ def fixture( def fixture( - fixture_function: Optional[_FixtureFunction] = None, + fixture_function: Optional[FixtureFunction] = None, *, scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = "function", params: Optional[Iterable[object]] = None, @@ -1288,7 +1288,7 @@ def fixture( ] ] = None, name: Optional[str] = None, -) -> Union[FixtureFunctionMarker, _FixtureFunction]: +) -> Union[FixtureFunctionMarker, FixtureFunction]: """Decorator to mark a fixture factory function. This decorator can be used, with or without parameters, to define a 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/python_api.py b/src/_pytest/python_api.py index 2285e22a3..eb4a0bd1b 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -573,31 +573,31 @@ def _as_numpy_array(obj: object) -> Optional["ndarray"]: # builtin pytest.raises helper -_E = TypeVar("_E", bound=BaseException) +E = TypeVar("E", bound=BaseException) @overload def raises( - expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], + expected_exception: Union[Type[E], Tuple[Type[E], ...]], *, match: Optional[Union[str, Pattern[str]]] = ..., -) -> "RaisesContext[_E]": +) -> "RaisesContext[E]": ... @overload def raises( - expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], + expected_exception: Union[Type[E], Tuple[Type[E], ...]], func: Callable[..., Any], *args: Any, **kwargs: Any, -) -> _pytest._code.ExceptionInfo[_E]: +) -> _pytest._code.ExceptionInfo[E]: ... def raises( - expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], *args: Any, **kwargs: Any -) -> Union["RaisesContext[_E]", _pytest._code.ExceptionInfo[_E]]: + expected_exception: Union[Type[E], Tuple[Type[E], ...]], *args: Any, **kwargs: Any +) -> Union["RaisesContext[E]", _pytest._code.ExceptionInfo[E]]: r"""Assert that a code block/function call raises ``expected_exception`` or raise a failure exception otherwise. @@ -711,7 +711,7 @@ def raises( __tracebackhide__ = True if isinstance(expected_exception, type): - excepted_exceptions: Tuple[Type[_E], ...] = (expected_exception,) + excepted_exceptions: Tuple[Type[E], ...] = (expected_exception,) else: excepted_exceptions = expected_exception for exc in excepted_exceptions: @@ -752,19 +752,19 @@ raises.Exception = fail.Exception # type: ignore @final -class RaisesContext(Generic[_E]): +class RaisesContext(Generic[E]): def __init__( self, - expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], + expected_exception: Union[Type[E], Tuple[Type[E], ...]], message: str, match_expr: Optional[Union[str, Pattern[str]]] = None, ) -> None: self.expected_exception = expected_exception self.message = message self.match_expr = match_expr - self.excinfo: Optional[_pytest._code.ExceptionInfo[_E]] = None + self.excinfo: Optional[_pytest._code.ExceptionInfo[E]] = None - def __enter__(self) -> _pytest._code.ExceptionInfo[_E]: + def __enter__(self) -> _pytest._code.ExceptionInfo[E]: self.excinfo = _pytest._code.ExceptionInfo.for_later() return self.excinfo @@ -781,7 +781,7 @@ class RaisesContext(Generic[_E]): if not issubclass(exc_type, self.expected_exception): return False # Cast to narrow the exception type now that it's verified. - exc_info = cast(Tuple[Type[_E], _E, TracebackType], (exc_type, exc_val, exc_tb)) + exc_info = cast(Tuple[Type[E], E, TracebackType], (exc_type, exc_val, exc_tb)) self.excinfo.fill_unfilled(exc_info) if self.match_expr is not None: self.excinfo.match(self.match_expr) 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 """