Merge branch 'pytest-dev:main' into fix_none_failure_with_pytrace_false

This commit is contained in:
Alex 2023-04-15 17:35:20 -04:00 committed by GitHub
commit e4964ff580
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 156 additions and 99 deletions

View File

@ -1 +0,0 @@
Python 3.12 support: fixed ``RuntimeError: TestResult has no addDuration method`` when running ``unittest`` tests.

View File

@ -1 +0,0 @@
Python 3.12 support: fixed ``shutil.rmtree(onerror=...)`` deprecation warning when using :fixture:`tmp_path`.

View File

@ -0,0 +1,2 @@
Added :func:`ExceptionInfo.from_exception() <pytest.ExceptionInfo.from_exception>`, a simpler way to create an :class:`~pytest.ExceptionInfo` from an exception.
This can replace :func:`ExceptionInfo.from_exc_info() <pytest.ExceptionInfo.from_exc_info()>` for most uses.

View File

@ -6,6 +6,7 @@ Release announcements
:maxdepth: 2 :maxdepth: 2
release-7.3.1
release-7.3.0 release-7.3.0
release-7.2.2 release-7.2.2
release-7.2.1 release-7.2.1

View File

@ -0,0 +1,18 @@
pytest-7.3.1
=======================================
pytest 7.3.1 has just been released to PyPI.
This is a bug-fix release, being a drop-in replacement. To upgrade::
pip install --upgrade pytest
The full changelog is available at https://docs.pytest.org/en/stable/changelog.html.
Thanks to all of the contributors to this release:
* Ran Benita
Happy testing,
The pytest Development Team

View File

@ -28,6 +28,29 @@ with advance notice in the **Deprecations** section of releases.
.. towncrier release notes start .. towncrier release notes start
pytest 7.3.1 (2023-04-14)
=========================
Improvements
------------
- `#10875 <https://github.com/pytest-dev/pytest/issues/10875>`_: Python 3.12 support: fixed ``RuntimeError: TestResult has no addDuration method`` when running ``unittest`` tests.
- `#10890 <https://github.com/pytest-dev/pytest/issues/10890>`_: Python 3.12 support: fixed ``shutil.rmtree(onerror=...)`` deprecation warning when using :fixture:`tmp_path`.
Bug Fixes
---------
- `#10896 <https://github.com/pytest-dev/pytest/issues/10896>`_: Fixed performance regression related to :fixture:`tmp_path` and the new :confval:`tmp_path_retention_policy` option.
- `#10903 <https://github.com/pytest-dev/pytest/issues/10903>`_: Fix crash ``INTERNALERROR IndexError: list index out of range`` which happens when displaying an exception where all entries are hidden.
This reverts the change "Correctly handle ``__tracebackhide__`` for chained exceptions." introduced in version 7.3.0.
pytest 7.3.0 (2023-04-08) pytest 7.3.0 (2023-04-08)
========================= =========================
@ -82,6 +105,7 @@ Bug Fixes
- `#1904 <https://github.com/pytest-dev/pytest/issues/1904>`_: Correctly handle ``__tracebackhide__`` for chained exceptions. - `#1904 <https://github.com/pytest-dev/pytest/issues/1904>`_: Correctly handle ``__tracebackhide__`` for chained exceptions.
NOTE: This change was reverted in version 7.3.1.

View File

@ -22,7 +22,7 @@ Install ``pytest``
.. code-block:: bash .. code-block:: bash
$ pytest --version $ pytest --version
pytest 7.3.0 pytest 7.3.1
.. _`simpletest`: .. _`simpletest`:

View File

@ -412,7 +412,8 @@ class Traceback(List[TracebackEntry]):
return Traceback(filter(fn, self), self._excinfo) return Traceback(filter(fn, self), self._excinfo)
def getcrashentry(self) -> Optional[TracebackEntry]: def getcrashentry(self) -> Optional[TracebackEntry]:
"""Return last non-hidden traceback entry that lead to the exception of a traceback.""" """Return last non-hidden traceback entry that lead to the exception of
a traceback, or None if all hidden."""
for i in range(-1, -len(self) - 1, -1): for i in range(-1, -len(self) - 1, -1):
entry = self[i] entry = self[i]
if not entry.ishidden(): if not entry.ishidden():
@ -469,22 +470,41 @@ class ExceptionInfo(Generic[E]):
self._traceback = traceback self._traceback = traceback
@classmethod @classmethod
def from_exc_info( def from_exception(
cls, cls,
exc_info: Tuple[Type[E], E, TracebackType], # Ignoring error: "Cannot use a covariant type variable as a parameter".
# This is OK to ignore because this class is (conceptually) readonly.
# See https://github.com/python/mypy/issues/7049.
exception: E, # type: ignore[misc]
exprinfo: Optional[str] = None, exprinfo: Optional[str] = None,
) -> "ExceptionInfo[E]": ) -> "ExceptionInfo[E]":
"""Return an ExceptionInfo for an existing exc_info tuple. """Return an ExceptionInfo for an existing exception.
.. warning:: The exception must have a non-``None`` ``__traceback__`` attribute,
otherwise this function fails with an assertion error. This means that
Experimental API the exception must have been raised, or added a traceback with the
:py:meth:`~BaseException.with_traceback()` method.
:param exprinfo: :param exprinfo:
A text string helping to determine if we should strip A text string helping to determine if we should strip
``AssertionError`` from the output. Defaults to the exception ``AssertionError`` from the output. Defaults to the exception
message/``__str__()``. message/``__str__()``.
.. versionadded:: 7.4
""" """
assert (
exception.__traceback__
), "Exceptions passed to ExcInfo.from_exception(...) must have a non-None __traceback__."
exc_info = (type(exception), exception, exception.__traceback__)
return cls.from_exc_info(exc_info, exprinfo)
@classmethod
def from_exc_info(
cls,
exc_info: Tuple[Type[E], E, TracebackType],
exprinfo: Optional[str] = None,
) -> "ExceptionInfo[E]":
"""Like :func:`from_exception`, but using old-style exc_info tuple."""
_striptext = "" _striptext = ""
if exprinfo is None and isinstance(exc_info[1], AssertionError): if exprinfo is None and isinstance(exc_info[1], AssertionError):
exprinfo = getattr(exc_info[1], "msg", None) exprinfo = getattr(exc_info[1], "msg", None)
@ -605,10 +625,10 @@ class ExceptionInfo(Generic[E]):
def _getreprcrash(self) -> Optional["ReprFileLocation"]: def _getreprcrash(self) -> Optional["ReprFileLocation"]:
exconly = self.exconly(tryshort=True) exconly = self.exconly(tryshort=True)
entry = self.traceback.getcrashentry() entry = self.traceback.getcrashentry()
if entry: if entry is None:
path, lineno = entry.frame.code.raw.co_filename, entry.lineno return None
return ReprFileLocation(path, lineno + 1, exconly) path, lineno = entry.frame.code.raw.co_filename, entry.lineno
return None return ReprFileLocation(path, lineno + 1, exconly)
def getrepr( def getrepr(
self, self,
@ -653,7 +673,9 @@ class ExceptionInfo(Generic[E]):
return ReprExceptionInfo( return ReprExceptionInfo(
reprtraceback=ReprTracebackNative( reprtraceback=ReprTracebackNative(
traceback.format_exception( traceback.format_exception(
self.type, self.value, self.traceback[0]._rawentry self.type,
self.value,
self.traceback[0]._rawentry if self.traceback else None,
) )
), ),
reprcrash=self._getreprcrash(), reprcrash=self._getreprcrash(),
@ -809,12 +831,16 @@ class FormattedExcinfo:
def repr_traceback_entry( def repr_traceback_entry(
self, self,
entry: TracebackEntry, entry: Optional[TracebackEntry],
excinfo: Optional[ExceptionInfo[BaseException]] = None, excinfo: Optional[ExceptionInfo[BaseException]] = None,
) -> "ReprEntry": ) -> "ReprEntry":
lines: List[str] = [] lines: List[str] = []
style = entry._repr_style if entry._repr_style is not None else self.style style = (
if style in ("short", "long"): entry._repr_style
if entry is not None and entry._repr_style is not None
else self.style
)
if style in ("short", "long") and entry is not None:
source = self._getentrysource(entry) source = self._getentrysource(entry)
if source is None: if source is None:
source = Source("???") source = Source("???")
@ -863,17 +889,21 @@ class FormattedExcinfo:
else: else:
extraline = None extraline = None
if not traceback:
if extraline is None:
extraline = "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames."
entries = [self.repr_traceback_entry(None, excinfo)]
return ReprTraceback(entries, extraline, style=self.style)
last = traceback[-1] last = traceback[-1]
entries = []
if self.style == "value": if self.style == "value":
reprentry = self.repr_traceback_entry(last, excinfo) entries = [self.repr_traceback_entry(last, excinfo)]
entries.append(reprentry)
return ReprTraceback(entries, None, style=self.style) return ReprTraceback(entries, None, style=self.style)
for index, entry in enumerate(traceback): entries = [
einfo = (last == entry) and excinfo or None self.repr_traceback_entry(entry, excinfo if last == entry else None)
reprentry = self.repr_traceback_entry(entry, einfo) for entry in traceback
entries.append(reprentry) ]
return ReprTraceback(entries, extraline, style=self.style) return ReprTraceback(entries, extraline, style=self.style)
def _truncate_recursive_traceback( def _truncate_recursive_traceback(
@ -930,6 +960,7 @@ class FormattedExcinfo:
seen: Set[int] = set() seen: Set[int] = set()
while e is not None and id(e) not in seen: while e is not None and id(e) not in seen:
seen.add(id(e)) seen.add(id(e))
if excinfo_: if excinfo_:
# Fall back to native traceback as a temporary workaround until # Fall back to native traceback as a temporary workaround until
# full support for exception groups added to ExceptionInfo. # full support for exception groups added to ExceptionInfo.
@ -946,14 +977,9 @@ class FormattedExcinfo:
) )
else: else:
reprtraceback = self.repr_traceback(excinfo_) reprtraceback = self.repr_traceback(excinfo_)
reprcrash: Optional[ReprFileLocation] = (
# will be None if all traceback entries are hidden excinfo_._getreprcrash() if self.style != "value" else None
reprcrash: Optional[ReprFileLocation] = excinfo_._getreprcrash() )
if reprcrash:
if self.style == "value":
repr_chain += [(reprtraceback, None, descr)]
else:
repr_chain += [(reprtraceback, reprcrash, descr)]
else: else:
# Fallback to native repr if the exception doesn't have a traceback: # Fallback to native repr if the exception doesn't have a traceback:
# ExceptionInfo objects require a full traceback to work. # ExceptionInfo objects require a full traceback to work.
@ -961,25 +987,17 @@ class FormattedExcinfo:
traceback.format_exception(type(e), e, None) traceback.format_exception(type(e), e, None)
) )
reprcrash = None reprcrash = None
repr_chain += [(reprtraceback, reprcrash, descr)] repr_chain += [(reprtraceback, reprcrash, descr)]
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.from_exception(e) if e.__traceback__ else None
ExceptionInfo.from_exc_info((type(e), e, e.__traceback__))
if e.__traceback__
else None
)
descr = "The above exception was the direct cause of the following exception:" descr = "The above exception was the direct cause of the following exception:"
elif ( elif (
e.__context__ is not None and not e.__suppress_context__ and self.chain e.__context__ is not None and not e.__suppress_context__ and self.chain
): ):
e = e.__context__ e = e.__context__
excinfo_ = ( excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
ExceptionInfo.from_exc_info((type(e), e, e.__traceback__))
if e.__traceback__
else None
)
descr = "During handling of the above exception, another exception occurred:" descr = "During handling of the above exception, another exception occurred:"
else: else:
e = None e = None
@ -1158,8 +1176,8 @@ class ReprEntry(TerminalRepr):
def toterminal(self, tw: TerminalWriter) -> None: def toterminal(self, tw: TerminalWriter) -> None:
if self.style == "short": if self.style == "short":
assert self.reprfileloc is not None if self.reprfileloc:
self.reprfileloc.toterminal(tw) self.reprfileloc.toterminal(tw)
self._write_entry_lines(tw) self._write_entry_lines(tw)
if self.reprlocals: if self.reprlocals:
self.reprlocals.toterminal(tw, indent=" " * 8) self.reprlocals.toterminal(tw, indent=" " * 8)

View File

@ -452,10 +452,7 @@ class Node(metaclass=NodeMeta):
if self.config.getoption("fulltrace", False): if self.config.getoption("fulltrace", False):
style = "long" style = "long"
else: else:
tb = _pytest._code.Traceback([excinfo.traceback[-1]])
self._prunetraceback(excinfo) self._prunetraceback(excinfo)
if len(excinfo.traceback) == 0:
excinfo.traceback = tb
if style == "auto": if style == "auto":
style = "long" style = "long"
# XXX should excinfo.getrepr record all data and toterminal() process it? # XXX should excinfo.getrepr record all data and toterminal() process it?

View File

@ -353,7 +353,7 @@ def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]:
yield path yield path
def cleanup_dead_symlink(root: Path): def cleanup_dead_symlinks(root: Path):
for left_dir in root.iterdir(): for left_dir in root.iterdir():
if left_dir.is_symlink(): if left_dir.is_symlink():
if not left_dir.resolve().exists(): if not left_dir.resolve().exists():
@ -371,7 +371,7 @@ def cleanup_numbered_dir(
for path in root.glob("garbage-*"): for path in root.glob("garbage-*"):
try_cleanup(path, consider_lock_dead_if_created_before) try_cleanup(path, consider_lock_dead_if_created_before)
cleanup_dead_symlink(root) cleanup_dead_symlinks(root)
def make_numbered_dir_with_cleanup( def make_numbered_dir_with_cleanup(

View File

@ -950,11 +950,7 @@ def raises( # noqa: F811
try: try:
func(*args[1:], **kwargs) func(*args[1:], **kwargs)
except expected_exception as e: except expected_exception as e:
# We just caught the exception - there is a traceback. return _pytest._code.ExceptionInfo.from_exception(e)
assert e.__traceback__ is not None
return _pytest._code.ExceptionInfo.from_exc_info(
(type(e), e, e.__traceback__)
)
fail(message) fail(message)

View File

@ -347,10 +347,9 @@ class TestReport(BaseReport):
elif isinstance(excinfo.value, skip.Exception): elif isinstance(excinfo.value, skip.Exception):
outcome = "skipped" outcome = "skipped"
r = excinfo._getreprcrash() r = excinfo._getreprcrash()
if r is None: assert (
raise ValueError( r is not None
"There should always be a traceback entry for skipping a test." ), "There should always be a traceback entry for skipping a test."
)
if excinfo.value._use_item_location: if excinfo.value._use_item_location:
path, line = item.reportinfo()[:2] path, line = item.reportinfo()[:2]
assert line is not None assert line is not None

View File

@ -28,7 +28,7 @@ from .pathlib import LOCK_TIMEOUT
from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir
from .pathlib import make_numbered_dir_with_cleanup from .pathlib import make_numbered_dir_with_cleanup
from .pathlib import rm_rf from .pathlib import rm_rf
from .pathlib import cleanup_dead_symlink from .pathlib import cleanup_dead_symlinks
from _pytest.compat import final, get_user_id from _pytest.compat import final, get_user_id
from _pytest.config import Config from _pytest.config import Config
from _pytest.config import ExitCode from _pytest.config import ExitCode
@ -289,31 +289,30 @@ def tmp_path(
del request.node.stash[tmppath_result_key] del request.node.stash[tmppath_result_key]
# remove dead symlink
basetemp = tmp_path_factory._basetemp
if basetemp is None:
return
cleanup_dead_symlink(basetemp)
def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]): def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]):
"""After each session, remove base directory if all the tests passed, """After each session, remove base directory if all the tests passed,
the policy is "failed", and the basetemp is not specified by a user. the policy is "failed", and the basetemp is not specified by a user.
""" """
tmp_path_factory: TempPathFactory = session.config._tmp_path_factory tmp_path_factory: TempPathFactory = session.config._tmp_path_factory
if tmp_path_factory._basetemp is None: basetemp = tmp_path_factory._basetemp
if basetemp is None:
return return
policy = tmp_path_factory._retention_policy policy = tmp_path_factory._retention_policy
if ( if (
exitstatus == 0 exitstatus == 0
and policy == "failed" and policy == "failed"
and tmp_path_factory._given_basetemp is None and tmp_path_factory._given_basetemp is None
): ):
passed_dir = tmp_path_factory._basetemp if basetemp.is_dir():
if passed_dir.exists():
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource, # We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
# permissions, etc, in which case we ignore it. # permissions, etc, in which case we ignore it.
rmtree(passed_dir, ignore_errors=True) rmtree(basetemp, ignore_errors=True)
# Remove dead symlinks.
if basetemp.is_dir():
cleanup_dead_symlinks(basetemp)
@hookimpl(tryfirst=True, hookwrapper=True) @hookimpl(tryfirst=True, hookwrapper=True)

View File

@ -53,6 +53,20 @@ def test_excinfo_from_exc_info_simple() -> None:
assert info.type == ValueError assert info.type == ValueError
def test_excinfo_from_exception_simple() -> None:
try:
raise ValueError
except ValueError as e:
assert e.__traceback__ is not None
info = _pytest._code.ExceptionInfo.from_exception(e)
assert info.type == ValueError
def test_excinfo_from_exception_missing_traceback_assertion() -> None:
with pytest.raises(AssertionError, match=r"must have.*__traceback__"):
_pytest._code.ExceptionInfo.from_exception(ValueError())
def test_excinfo_getstatement(): def test_excinfo_getstatement():
def g(): def g():
raise ValueError raise ValueError
@ -310,9 +324,7 @@ class TestTraceback_f_g_h:
g() g()
excinfo = pytest.raises(ValueError, f) excinfo = pytest.raises(ValueError, f)
tb = excinfo.traceback assert excinfo.traceback.getcrashentry() is None
entry = tb.getcrashentry()
assert entry is None
def test_excinfo_exconly(): def test_excinfo_exconly():
@ -1573,3 +1585,21 @@ def test_exceptiongroup(pytester: Pytester, outer_chain, inner_chain) -> None:
# with py>=3.11 does not depend on exceptiongroup, though there is a toxenv for it # with py>=3.11 does not depend on exceptiongroup, though there is a toxenv for it
pytest.importorskip("exceptiongroup") pytest.importorskip("exceptiongroup")
_exceptiongroup_common(pytester, outer_chain, inner_chain, native=False) _exceptiongroup_common(pytester, outer_chain, inner_chain, native=False)
@pytest.mark.parametrize("tbstyle", ("long", "short", "auto", "line", "native"))
def test_all_entries_hidden(pytester: Pytester, tbstyle: str) -> None:
"""Regression test for #10903."""
pytester.makepyfile(
"""
def test():
__tracebackhide__ = True
1 / 0
"""
)
result = pytester.runpytest("--tb", tbstyle)
assert result.ret == 1
if tbstyle != "line":
result.stdout.fnmatch_lines(["*ZeroDivisionError: division by zero"])
if tbstyle not in ("line", "native"):
result.stdout.fnmatch_lines(["All traceback entries are hidden.*"])

View File

@ -1,25 +0,0 @@
def test_tbh_chained(testdir):
"""Ensure chained exceptions whose frames contain "__tracebackhide__" are not shown (#1904)."""
p = testdir.makepyfile(
"""
import pytest
def f1():
__tracebackhide__ = True
try:
return f1.meh
except AttributeError:
pytest.fail("fail")
@pytest.fixture
def fix():
f1()
def test(fix):
pass
"""
)
result = testdir.runpytest(str(p))
assert "'function' object has no attribute 'meh'" not in result.stdout.str()
assert result.ret == 1