diff --git a/changelog/10875.improvement.rst b/changelog/10875.improvement.rst deleted file mode 100644 index eeaf04635..000000000 --- a/changelog/10875.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Python 3.12 support: fixed ``RuntimeError: TestResult has no addDuration method`` when running ``unittest`` tests. diff --git a/changelog/10890.improvement.rst b/changelog/10890.improvement.rst deleted file mode 100644 index 9c367da31..000000000 --- a/changelog/10890.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Python 3.12 support: fixed ``shutil.rmtree(onerror=...)`` deprecation warning when using :fixture:`tmp_path`. diff --git a/changelog/10901.feature.rst b/changelog/10901.feature.rst new file mode 100644 index 000000000..0d99d66f6 --- /dev/null +++ b/changelog/10901.feature.rst @@ -0,0 +1,2 @@ +Added :func:`ExceptionInfo.from_exception() `, a simpler way to create an :class:`~pytest.ExceptionInfo` from an exception. +This can replace :func:`ExceptionInfo.from_exc_info() ` for most uses. diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 96db2e248..e3919f88e 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-7.3.1 release-7.3.0 release-7.2.2 release-7.2.1 diff --git a/doc/en/announce/release-7.3.1.rst b/doc/en/announce/release-7.3.1.rst new file mode 100644 index 000000000..e920fa8af --- /dev/null +++ b/doc/en/announce/release-7.3.1.rst @@ -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 diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index e57e4fd72..c13c05936 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -28,6 +28,29 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 7.3.1 (2023-04-14) +========================= + +Improvements +------------ + +- `#10875 `_: Python 3.12 support: fixed ``RuntimeError: TestResult has no addDuration method`` when running ``unittest`` tests. + + +- `#10890 `_: Python 3.12 support: fixed ``shutil.rmtree(onerror=...)`` deprecation warning when using :fixture:`tmp_path`. + + + +Bug Fixes +--------- + +- `#10896 `_: Fixed performance regression related to :fixture:`tmp_path` and the new :confval:`tmp_path_retention_policy` option. + + +- `#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) ========================= @@ -82,6 +105,7 @@ Bug Fixes - `#1904 `_: Correctly handle ``__tracebackhide__`` for chained exceptions. + NOTE: This change was reverted in version 7.3.1. diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 8c9c4e75a..f41571141 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -22,7 +22,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - pytest 7.3.0 + pytest 7.3.1 .. _`simpletest`: diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index aa953d985..5d46dd998 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -412,7 +412,8 @@ class Traceback(List[TracebackEntry]): return Traceback(filter(fn, self), self._excinfo) 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): entry = self[i] if not entry.ishidden(): @@ -469,22 +470,41 @@ class ExceptionInfo(Generic[E]): self._traceback = traceback @classmethod - def from_exc_info( + def from_exception( 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, ) -> "ExceptionInfo[E]": - """Return an ExceptionInfo for an existing exc_info tuple. + """Return an ExceptionInfo for an existing exception. - .. warning:: - - Experimental API + The exception must have a non-``None`` ``__traceback__`` attribute, + otherwise this function fails with an assertion error. This means that + the exception must have been raised, or added a traceback with the + :py:meth:`~BaseException.with_traceback()` method. :param exprinfo: A text string helping to determine if we should strip ``AssertionError`` from the output. Defaults to the exception 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 = "" if exprinfo is None and isinstance(exc_info[1], AssertionError): exprinfo = getattr(exc_info[1], "msg", None) @@ -605,10 +625,10 @@ class ExceptionInfo(Generic[E]): def _getreprcrash(self) -> Optional["ReprFileLocation"]: exconly = self.exconly(tryshort=True) entry = self.traceback.getcrashentry() - if entry: - path, lineno = entry.frame.code.raw.co_filename, entry.lineno - return ReprFileLocation(path, lineno + 1, exconly) - return None + if entry is None: + return None + path, lineno = entry.frame.code.raw.co_filename, entry.lineno + return ReprFileLocation(path, lineno + 1, exconly) def getrepr( self, @@ -653,7 +673,9 @@ class ExceptionInfo(Generic[E]): return ReprExceptionInfo( reprtraceback=ReprTracebackNative( 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(), @@ -809,12 +831,16 @@ class FormattedExcinfo: def repr_traceback_entry( self, - entry: TracebackEntry, + entry: Optional[TracebackEntry], excinfo: Optional[ExceptionInfo[BaseException]] = None, ) -> "ReprEntry": lines: List[str] = [] - style = entry._repr_style if entry._repr_style is not None else self.style - if style in ("short", "long"): + style = ( + 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) if source is None: source = Source("???") @@ -863,17 +889,21 @@ class FormattedExcinfo: else: 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] - entries = [] if self.style == "value": - reprentry = self.repr_traceback_entry(last, excinfo) - entries.append(reprentry) + entries = [self.repr_traceback_entry(last, excinfo)] return ReprTraceback(entries, None, style=self.style) - for index, entry in enumerate(traceback): - einfo = (last == entry) and excinfo or None - reprentry = self.repr_traceback_entry(entry, einfo) - entries.append(reprentry) + entries = [ + self.repr_traceback_entry(entry, excinfo if last == entry else None) + for entry in traceback + ] return ReprTraceback(entries, extraline, style=self.style) def _truncate_recursive_traceback( @@ -930,6 +960,7 @@ class FormattedExcinfo: seen: Set[int] = set() while e is not None and id(e) not in seen: seen.add(id(e)) + if excinfo_: # Fall back to native traceback as a temporary workaround until # full support for exception groups added to ExceptionInfo. @@ -946,14 +977,9 @@ class FormattedExcinfo: ) else: reprtraceback = self.repr_traceback(excinfo_) - - # will be None if all traceback entries are hidden - reprcrash: Optional[ReprFileLocation] = excinfo_._getreprcrash() - if reprcrash: - if self.style == "value": - repr_chain += [(reprtraceback, None, descr)] - else: - repr_chain += [(reprtraceback, reprcrash, descr)] + reprcrash: Optional[ReprFileLocation] = ( + excinfo_._getreprcrash() if self.style != "value" else None + ) else: # Fallback to native repr if the exception doesn't have a traceback: # ExceptionInfo objects require a full traceback to work. @@ -961,25 +987,17 @@ class FormattedExcinfo: traceback.format_exception(type(e), e, None) ) reprcrash = None - repr_chain += [(reprtraceback, reprcrash, descr)] + repr_chain += [(reprtraceback, reprcrash, descr)] if e.__cause__ is not None and self.chain: e = e.__cause__ - excinfo_ = ( - ExceptionInfo.from_exc_info((type(e), e, e.__traceback__)) - if e.__traceback__ - else None - ) + excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None descr = "The above exception was the direct cause of the following exception:" elif ( e.__context__ is not None and not e.__suppress_context__ and self.chain ): e = e.__context__ - excinfo_ = ( - ExceptionInfo.from_exc_info((type(e), e, e.__traceback__)) - if e.__traceback__ - else None - ) + excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None descr = "During handling of the above exception, another exception occurred:" else: e = None @@ -1158,8 +1176,8 @@ class ReprEntry(TerminalRepr): def toterminal(self, tw: TerminalWriter) -> None: if self.style == "short": - assert self.reprfileloc is not None - self.reprfileloc.toterminal(tw) + if self.reprfileloc: + self.reprfileloc.toterminal(tw) self._write_entry_lines(tw) if self.reprlocals: self.reprlocals.toterminal(tw, indent=" " * 8) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index b9af84c12..ad4b62155 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -452,10 +452,7 @@ class Node(metaclass=NodeMeta): if self.config.getoption("fulltrace", False): style = "long" else: - tb = _pytest._code.Traceback([excinfo.traceback[-1]]) self._prunetraceback(excinfo) - if len(excinfo.traceback) == 0: - excinfo.traceback = tb if style == "auto": style = "long" # XXX should excinfo.getrepr record all data and toterminal() process it? diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 30ed76cf8..2c9d5870b 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -353,7 +353,7 @@ def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]: yield path -def cleanup_dead_symlink(root: Path): +def cleanup_dead_symlinks(root: Path): for left_dir in root.iterdir(): if left_dir.is_symlink(): if not left_dir.resolve().exists(): @@ -371,7 +371,7 @@ def cleanup_numbered_dir( for path in root.glob("garbage-*"): try_cleanup(path, consider_lock_dead_if_created_before) - cleanup_dead_symlink(root) + cleanup_dead_symlinks(root) def make_numbered_dir_with_cleanup( diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index b03a251ab..4213bd098 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -950,11 +950,7 @@ def raises( # noqa: F811 try: func(*args[1:], **kwargs) except expected_exception as e: - # We just caught the exception - there is a traceback. - assert e.__traceback__ is not None - return _pytest._code.ExceptionInfo.from_exc_info( - (type(e), e, e.__traceback__) - ) + return _pytest._code.ExceptionInfo.from_exception(e) fail(message) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index c0a76f92b..74e8794b2 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -347,10 +347,9 @@ class TestReport(BaseReport): elif isinstance(excinfo.value, skip.Exception): outcome = "skipped" r = excinfo._getreprcrash() - if r is None: - raise ValueError( - "There should always be a traceback entry for skipping a test." - ) + assert ( + r is not None + ), "There should always be a traceback entry for skipping a test." if excinfo.value._use_item_location: path, line = item.reportinfo()[:2] assert line is not None diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 5f347665f..d7f5ab9b4 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -28,7 +28,7 @@ from .pathlib import LOCK_TIMEOUT from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup 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.config import Config from _pytest.config import ExitCode @@ -289,31 +289,30 @@ def tmp_path( 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]): """After each session, remove base directory if all the tests passed, the policy is "failed", and the basetemp is not specified by a user. """ 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 + policy = tmp_path_factory._retention_policy if ( exitstatus == 0 and policy == "failed" and tmp_path_factory._given_basetemp is None ): - passed_dir = tmp_path_factory._basetemp - if passed_dir.exists(): + if basetemp.is_dir(): # 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. - 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) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 918c97276..6a720e64c 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -53,6 +53,20 @@ def test_excinfo_from_exc_info_simple() -> None: 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 g(): raise ValueError @@ -310,9 +324,7 @@ class TestTraceback_f_g_h: g() excinfo = pytest.raises(ValueError, f) - tb = excinfo.traceback - entry = tb.getcrashentry() - assert entry is None + assert excinfo.traceback.getcrashentry() is None 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 pytest.importorskip("exceptiongroup") _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.*"]) diff --git a/testing/test_tracebackhide.py b/testing/test_tracebackhide.py deleted file mode 100644 index 88f9c4fc0..000000000 --- a/testing/test_tracebackhide.py +++ /dev/null @@ -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