diff --git a/AUTHORS b/AUTHORS index d7148acfc..4f61c0591 100644 --- a/AUTHORS +++ b/AUTHORS @@ -101,6 +101,7 @@ Cyrus Maden Damian Skrzypczak Daniel Grana Daniel Hahler +Daniel Miller Daniel Nuri Daniel Sánchez Castelló Daniel Valenzuela Zenteno diff --git a/changelog/11523.improvement.rst b/changelog/11523.improvement.rst new file mode 100644 index 000000000..f7d8ff89d --- /dev/null +++ b/changelog/11523.improvement.rst @@ -0,0 +1,5 @@ +:func:`pytest.importorskip` will now issue a warning if the module could be found, but raised :class:`ImportError` instead of :class:`ModuleNotFoundError`. + +The warning can be suppressed by passing ``exc_type=ImportError`` to :func:`pytest.importorskip`. + +See :ref:`import-or-skip-import-error` for details. diff --git a/changelog/11728.improvement.rst b/changelog/11728.improvement.rst new file mode 100644 index 000000000..1e87fc5ed --- /dev/null +++ b/changelog/11728.improvement.rst @@ -0,0 +1 @@ +For ``unittest``-based tests, exceptions during class cleanup (as raised by functions registered with :meth:`TestCase.addClassCleanup `) are now reported instead of silently failing. diff --git a/changelog/12114.bugfix.rst b/changelog/12114.bugfix.rst deleted file mode 100644 index 798496925..000000000 --- a/changelog/12114.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed attribute error in pytest.approx for types implicitly convertible to numpy arrays by converting other_side to a numpy array so that np_array_shape != other_side.shape can be properly checked. diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 40eccdd74..bb39eb7e6 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-8.1.2 release-8.1.1 release-8.1.0 release-8.0.2 diff --git a/doc/en/announce/release-8.1.2.rst b/doc/en/announce/release-8.1.2.rst new file mode 100644 index 000000000..19e41e0f7 --- /dev/null +++ b/doc/en/announce/release-8.1.2.rst @@ -0,0 +1,18 @@ +pytest-8.1.2 +======================================= + +pytest 8.1.2 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: + +* Bruno Oliveira + + +Happy testing, +The pytest Development Team diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index bea4257af..fb316e706 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -28,6 +28,15 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 8.1.2 (2024-04-26) +========================= + +Bug Fixes +--------- + +- `#12114 `_: Fixed error in :func:`pytest.approx` when used with `numpy` arrays and comparing with other types. + + pytest 8.1.1 (2024-03-08) ========================= diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index cd6d1e60a..5ac93f151 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -19,6 +19,41 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +.. _import-or-skip-import-error: + +``pytest.importorskip`` default behavior regarding :class:`ImportError` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 8.2 + +Traditionally :func:`pytest.importorskip` will capture :class:`ImportError`, with the original intent being to skip +tests where a dependent module is not installed, for example testing with different dependencies. + +However some packages might be installed in the system, but are not importable due to +some other issue, for example, a compilation error or a broken installation. In those cases :func:`pytest.importorskip` +would still silently skip the test, but more often than not users would like to see the unexpected +error so the underlying issue can be fixed. + +In ``8.2`` the ``exc_type`` parameter has been added, giving users the ability of passing :class:`ModuleNotFoundError` +to skip tests only if the module cannot really be found, and not because of some other error. + +Catching only :class:`ModuleNotFoundError` by default (and letting other errors propagate) would be the best solution, +however for backward compatibility, pytest will keep the existing behavior but raise an warning if: + +1. The captured exception is of type :class:`ImportError`, and: +2. The user does not pass ``exc_type`` explicitly. + +If the import attempt raises :class:`ModuleNotFoundError` (the usual case), then the module is skipped and no +warning is emitted. + +This way, the usual cases will keep working the same way, while unexpected errors will now issue a warning, with +users being able to supress the warning by passing ``exc_type=ImportError`` explicitly. + +In ``9.0``, the warning will turn into an error, and in ``9.1`` :func:`pytest.importorskip` will only capture +:class:`ModuleNotFoundError` by default and no warnings will be issued anymore -- but users can still capture +:class:`ImportError` by passing it to ``exc_type``. + + .. _node-ctor-fspath-deprecation: ``fspath`` argument for Node constructors replaced with ``pathlib.Path`` diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index ad17ce0b4..672c7c445 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -162,7 +162,7 @@ objects, they are still using the default pytest representation: rootdir: /home/sweet/project collected 8 items - + @@ -239,7 +239,7 @@ If you just collect tests you'll also nicely see 'advanced' and 'basic' as varia rootdir: /home/sweet/project collected 4 items - + @@ -318,7 +318,7 @@ Let's first see how it looks like at collection time: rootdir: /home/sweet/project collected 2 items - + diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index 68737267e..c01e685f3 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -152,7 +152,7 @@ The test collection would look like this: configfile: pytest.ini collected 2 items - + @@ -215,7 +215,7 @@ You can always peek at the collection tree without running tests like this: configfile: pytest.ini collected 3 items - + diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 40632645d..e96eabbc5 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 8.1.1 + pytest 8.1.2 .. _`simpletest`: diff --git a/doc/en/how-to/fixtures.rst b/doc/en/how-to/fixtures.rst index 795d2caf5..ba416ccbc 100644 --- a/doc/en/how-to/fixtures.rst +++ b/doc/en/how-to/fixtures.rst @@ -1418,7 +1418,7 @@ Running the above tests results in the following test IDs being used: rootdir: /home/sweet/project collected 12 items - + diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 3b4a62a4f..c65ce79f7 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -52,7 +52,7 @@ from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath -if sys.version_info[:2] < (3, 11): +if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup _TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] @@ -703,7 +703,7 @@ class ExceptionInfo(Generic[E]): # Workaround for https://github.com/python/cpython/issues/98778 on # Python <= 3.9, and some 3.10 and 3.11 patch versions. HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ()) - if sys.version_info[:2] <= (3, 11) and isinstance(exc, HTTPError): + if sys.version_info < (3, 12) and isinstance(exc, HTTPError): notes = [] else: raise diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 95dc28d4a..9006351af 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -448,7 +448,7 @@ class MyOptionParser(argparse.ArgumentParser): getattr(parsed, FILE_OR_DIR).extend(unrecognized) return parsed - if sys.version_info[:2] < (3, 9): # pragma: no cover + if sys.version_info < (3, 9): # pragma: no cover # Backport of https://github.com/python/cpython/pull/14316 so we can # disable long --argument abbreviations without breaking short flags. def _parse_optional( diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5303948c4..09fd07422 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -35,6 +35,7 @@ import warnings import _pytest from _pytest import nodes from _pytest._code import getfslineno +from _pytest._code import Source from _pytest._code.code import FormattedExcinfo from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter @@ -69,7 +70,7 @@ from _pytest.scope import HIGH_SCOPES from _pytest.scope import Scope -if sys.version_info[:2] < (3, 11): +if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup @@ -410,41 +411,6 @@ class FixtureRequest(abc.ABC): """Underlying collection node (depends on current request scope).""" raise NotImplementedError() - def _getnextfixturedef(self, argname: str) -> "FixtureDef[Any]": - fixturedefs = self._arg2fixturedefs.get(argname, None) - if fixturedefs is None: - # We arrive here because of a dynamic call to - # getfixturevalue(argname) usage which was naturally - # not known at parsing/collection time. - fixturedefs = self._fixturemanager.getfixturedefs(argname, self._pyfuncitem) - if fixturedefs is not None: - self._arg2fixturedefs[argname] = fixturedefs - # No fixtures defined with this name. - if fixturedefs is None: - raise FixtureLookupError(argname, self) - # The are no fixtures with this name applicable for the function. - if not fixturedefs: - raise FixtureLookupError(argname, self) - - # A fixture may override another fixture with the same name, e.g. a - # fixture in a module can override a fixture in a conftest, a fixture in - # a class can override a fixture in the module, and so on. - # An overriding fixture can request its own name (possibly indirectly); - # in this case it gets the value of the fixture it overrides, one level - # up. - # Check how many `argname`s deep we are, and take the next one. - # `fixturedefs` is sorted from furthest to closest, so use negative - # indexing to go in reverse. - index = -1 - for request in self._iter_chain(): - if request.fixturename == argname: - index -= 1 - # If already consumed all of the available levels, fail. - if -index > len(fixturedefs): - raise FixtureLookupError(argname, self) - - return fixturedefs[index] - @property def config(self) -> Config: """The pytest config object associated with this request.""" @@ -569,39 +535,53 @@ class FixtureRequest(abc.ABC): def _get_active_fixturedef( self, argname: str ) -> Union["FixtureDef[object]", PseudoFixtureDef[object]]: + if argname == "request": + cached_result = (self, [0], None) + return PseudoFixtureDef(cached_result, Scope.Function) + + # If we already finished computing a fixture by this name in this item, + # return it. fixturedef = self._fixture_defs.get(argname) - if fixturedef is None: - try: - fixturedef = self._getnextfixturedef(argname) - except FixtureLookupError: - if argname == "request": - cached_result = (self, [0], None) - return PseudoFixtureDef(cached_result, Scope.Function) - raise - self._compute_fixture_value(fixturedef) - self._fixture_defs[argname] = fixturedef - else: + if fixturedef is not None: self._check_scope(fixturedef, fixturedef._scope) - return fixturedef + return fixturedef - def _get_fixturestack(self) -> List["FixtureDef[Any]"]: - values = [request._fixturedef for request in self._iter_chain()] - values.reverse() - return values + # Find the appropriate fixturedef. + fixturedefs = self._arg2fixturedefs.get(argname, None) + if fixturedefs is None: + # We arrive here because of a dynamic call to + # getfixturevalue(argname) which was naturally + # not known at parsing/collection time. + fixturedefs = self._fixturemanager.getfixturedefs(argname, self._pyfuncitem) + if fixturedefs is not None: + self._arg2fixturedefs[argname] = fixturedefs + # No fixtures defined with this name. + if fixturedefs is None: + raise FixtureLookupError(argname, self) + # The are no fixtures with this name applicable for the function. + if not fixturedefs: + raise FixtureLookupError(argname, self) + # A fixture may override another fixture with the same name, e.g. a + # fixture in a module can override a fixture in a conftest, a fixture in + # a class can override a fixture in the module, and so on. + # An overriding fixture can request its own name (possibly indirectly); + # in this case it gets the value of the fixture it overrides, one level + # up. + # Check how many `argname`s deep we are, and take the next one. + # `fixturedefs` is sorted from furthest to closest, so use negative + # indexing to go in reverse. + index = -1 + for request in self._iter_chain(): + if request.fixturename == argname: + index -= 1 + # If already consumed all of the available levels, fail. + if -index > len(fixturedefs): + raise FixtureLookupError(argname, self) + fixturedef = fixturedefs[index] - def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None: - """Create a SubRequest based on "self" and call the execute method - of the given FixtureDef object. - - If the FixtureDef has cached the result it will do nothing, otherwise it will - setup and run the fixture, cache the value, and schedule a finalizer for it. - """ - # prepare a subrequest object before calling fixture function - # (latter managed by fixturedef) - argname = fixturedef.argname - funcitem = self._pyfuncitem + # Prepare a SubRequest object for calling the fixture. try: - callspec = funcitem.callspec + callspec = self._pyfuncitem.callspec except AttributeError: callspec = None if callspec is not None and argname in callspec.params: @@ -613,41 +593,8 @@ class FixtureRequest(abc.ABC): param = NOTSET param_index = 0 scope = fixturedef._scope - - has_params = fixturedef.params is not None - fixtures_not_supported = getattr(funcitem, "nofuncargs", False) - if has_params and fixtures_not_supported: - msg = ( - f"{funcitem.name} does not support fixtures, maybe unittest.TestCase subclass?\n" - f"Node id: {funcitem.nodeid}\n" - f"Function type: {type(funcitem).__name__}" - ) - fail(msg, pytrace=False) - if has_params: - frame = inspect.stack()[3] - frameinfo = inspect.getframeinfo(frame[0]) - source_path = absolutepath(frameinfo.filename) - source_lineno = frameinfo.lineno - try: - source_path_str = str( - source_path.relative_to(funcitem.config.rootpath) - ) - except ValueError: - source_path_str = str(source_path) - location = getlocation(fixturedef.func, funcitem.config.rootpath) - msg = ( - "The requested fixture has no parameter defined for test:\n" - f" {funcitem.nodeid}\n\n" - f"Requested fixture '{fixturedef.argname}' defined in:\n" - f"{location}\n\n" - f"Requested here:\n" - f"{source_path_str}:{source_lineno}" - ) - fail(msg, pytrace=False) - - # Check if a higher-level scoped fixture accesses a lower level one. + self._check_fixturedef_without_param(fixturedef) self._check_scope(fixturedef, scope) - subrequest = SubRequest( self, scope, param, param_index, fixturedef, _ispytest=True ) @@ -655,6 +602,47 @@ class FixtureRequest(abc.ABC): # Make sure the fixture value is cached, running it if it isn't fixturedef.execute(request=subrequest) + self._fixture_defs[argname] = fixturedef + return fixturedef + + def _check_fixturedef_without_param(self, fixturedef: "FixtureDef[object]") -> None: + """Check that this request is allowed to execute this fixturedef without + a param.""" + funcitem = self._pyfuncitem + has_params = fixturedef.params is not None + fixtures_not_supported = getattr(funcitem, "nofuncargs", False) + if has_params and fixtures_not_supported: + msg = ( + f"{funcitem.name} does not support fixtures, maybe unittest.TestCase subclass?\n" + f"Node id: {funcitem.nodeid}\n" + f"Function type: {type(funcitem).__name__}" + ) + fail(msg, pytrace=False) + if has_params: + frame = inspect.stack()[3] + frameinfo = inspect.getframeinfo(frame[0]) + source_path = absolutepath(frameinfo.filename) + source_lineno = frameinfo.lineno + try: + source_path_str = str(source_path.relative_to(funcitem.config.rootpath)) + except ValueError: + source_path_str = str(source_path) + location = getlocation(fixturedef.func, funcitem.config.rootpath) + msg = ( + "The requested fixture has no parameter defined for test:\n" + f" {funcitem.nodeid}\n\n" + f"Requested fixture '{fixturedef.argname}' defined in:\n" + f"{location}\n\n" + f"Requested here:\n" + f"{source_path_str}:{source_lineno}" + ) + fail(msg, pytrace=False) + + def _get_fixturestack(self) -> List["FixtureDef[Any]"]: + values = [request._fixturedef for request in self._iter_chain()] + values.reverse() + return values + @final class TopRequest(FixtureRequest): @@ -877,13 +865,6 @@ class FixtureLookupErrorRepr(TerminalRepr): tw.line("%s:%d" % (os.fspath(self.filename), self.firstlineno + 1)) -def fail_fixturefunc(fixturefunc, msg: str) -> NoReturn: - fs, lineno = getfslineno(fixturefunc) - location = f"{fs}:{lineno + 1}" - source = _pytest._code.Source(fixturefunc) - fail(msg + ":\n\n" + str(source.indent()) + "\n" + location, pytrace=False) - - def call_fixture_func( fixturefunc: "_FixtureFunc[FixtureValue]", request: FixtureRequest, kwargs ) -> FixtureValue: @@ -913,7 +894,13 @@ def _teardown_yield_fixture(fixturefunc, it) -> None: except StopIteration: pass else: - fail_fixturefunc(fixturefunc, "fixture function has more than one 'yield'") + fs, lineno = getfslineno(fixturefunc) + fail( + f"fixture function has more than one 'yield':\n\n" + f"{Source(fixturefunc).indent()}\n" + f"{fs}:{lineno + 1}", + pytrace=False, + ) def _eval_scope_callable( diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 1b91bdb6e..974d756a2 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -765,7 +765,7 @@ class Item(Node, abc.ABC): and lineno is a 0-based line number. """ location = self.reportinfo() - path = absolutepath(os.fspath(location[0])) + path = absolutepath(location[0]) relfspath = self.session._node_location_to_relpath(path) assert type(location[2]) is str return (relfspath, location[1], location[2]) diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index e2a816f58..76d94accd 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -11,6 +11,8 @@ from typing import Protocol from typing import Type from typing import TypeVar +from .warning_types import PytestDeprecationWarning + class OutcomeException(BaseException): """OutcomeException and its subclass instances indicate and contain info @@ -192,7 +194,11 @@ def xfail(reason: str = "") -> NoReturn: def importorskip( - modname: str, minversion: Optional[str] = None, reason: Optional[str] = None + modname: str, + minversion: Optional[str] = None, + reason: Optional[str] = None, + *, + exc_type: Optional[Type[ImportError]] = None, ) -> Any: """Import and return the requested module ``modname``, or skip the current test if the module cannot be imported. @@ -205,6 +211,18 @@ def importorskip( :param reason: If given, this reason is shown as the message when the module cannot be imported. + :param exc_type: + The exception that should be captured in order to skip modules. + Must be :py:class:`ImportError` or a subclass. + + If the module can be imported but raises :class:`ImportError`, pytest will + issue a warning to the user, as often users expect the module not to be + found (which would raise :class:`ModuleNotFoundError` instead). + + This warning can be suppressed by passing ``exc_type=ImportError`` explicitly. + + See :ref:`import-or-skip-import-error` for details. + :returns: The imported module. This should be assigned to its canonical name. @@ -212,23 +230,62 @@ def importorskip( Example:: docutils = pytest.importorskip("docutils") + + .. versionadded:: 8.2 + + The ``exc_type`` parameter. """ import warnings __tracebackhide__ = True compile(modname, "", "eval") # to catch syntaxerrors + # Until pytest 9.1, we will warn the user if we catch ImportError (instead of ModuleNotFoundError), + # as this might be hiding an installation/environment problem, which is not usually what is intended + # when using importorskip() (#11523). + # In 9.1, to keep the function signature compatible, we just change the code below to: + # 1. Use `exc_type = ModuleNotFoundError` if `exc_type` is not given. + # 2. Remove `warn_on_import` and the warning handling. + if exc_type is None: + exc_type = ImportError + warn_on_import_error = True + else: + warn_on_import_error = False + + skipped: Optional[Skipped] = None + warning: Optional[Warning] = None + with warnings.catch_warnings(): # Make sure to ignore ImportWarnings that might happen because # of existing directories with the same name we're trying to # import but without a __init__.py file. warnings.simplefilter("ignore") + try: __import__(modname) - except ImportError as exc: + except exc_type as exc: + # Do not raise or issue warnings inside the catch_warnings() block. if reason is None: reason = f"could not import {modname!r}: {exc}" - raise Skipped(reason, allow_module_level=True) from None + skipped = Skipped(reason, allow_module_level=True) + + if warn_on_import_error and not isinstance(exc, ModuleNotFoundError): + lines = [ + "", + f"Module '{modname}' was found, but when imported by pytest it raised:", + f" {exc!r}", + "In pytest 9.1 this warning will become an error by default.", + "You can fix the underlying problem, or alternatively overwrite this behavior and silence this " + "warning by passing exc_type=ImportError explicitly.", + "See https://docs.pytest.org/en/stable/deprecations.html#pytest-importorskip-default-behavior-regarding-importerror", + ] + warning = PytestDeprecationWarning("\n".join(lines)) + + if warning: + warnings.warn(warning, stacklevel=2) + if skipped: + raise skipped + mod = sys.modules[modname] if minversion is None: return mod diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 190d9dd8c..e14c2acd3 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -924,13 +924,13 @@ def visit( yield from visit(entry.path, recurse) -def absolutepath(path: Union[Path, str]) -> Path: +def absolutepath(path: "Union[str, os.PathLike[str]]") -> Path: """Convert a path to an absolute path using os.path.abspath. Prefer this over Path.resolve() (see #6523). Prefer this over Path.absolute() (not public, doesn't normalize). """ - return Path(os.path.abspath(str(path))) + return Path(os.path.abspath(path)) def commonpath(path1: Path, path2: Path) -> Optional[Path]: diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 3f706b927..9bc544ea7 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -39,7 +39,7 @@ from _pytest.outcomes import Skipped from _pytest.outcomes import TEST_OUTCOME -if sys.version_info[:2] < (3, 11): +if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup if TYPE_CHECKING: diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 5099904fd..8f1791bf7 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -32,6 +32,9 @@ from _pytest.runner import CallInfo import pytest +if sys.version_info[:2] < (3, 11): + from exceptiongroup import ExceptionGroup + if TYPE_CHECKING: import unittest @@ -111,6 +114,20 @@ class UnitTestCase(Class): return None cleanup = getattr(cls, "doClassCleanups", lambda: None) + def process_teardown_exceptions() -> None: + # tearDown_exceptions is a list set in the class containing exc_infos for errors during + # teardown for the class. + exc_infos = getattr(cls, "tearDown_exceptions", None) + if not exc_infos: + return + exceptions = [exc for (_, exc, _) in exc_infos] + # If a single exception, raise it directly as this provides a more readable + # error (hopefully this will improve in #12255). + if len(exceptions) == 1: + raise exceptions[0] + else: + raise ExceptionGroup("Unittest class cleanup errors", exceptions) + def unittest_setup_class_fixture( request: FixtureRequest, ) -> Generator[None, None, None]: @@ -125,6 +142,7 @@ class UnitTestCase(Class): # follow this here. except Exception: cleanup() + process_teardown_exceptions() raise yield try: @@ -132,6 +150,7 @@ class UnitTestCase(Class): teardown() finally: cleanup() + process_teardown_exceptions() self.session._fixturemanager._register_fixture( # Use a unique name to speed up lookup. diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 419c11abc..dd4bd22c8 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -28,7 +28,7 @@ import pytest if TYPE_CHECKING: from _pytest._code.code import _TracebackStyle -if sys.version_info[:2] < (3, 11): +if sys.version_info < (3, 11): from exceptiongroup import ExceptionGroup diff --git a/testing/test_runner.py b/testing/test_runner.py index ffac0f2d9..8b41ec28a 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -9,6 +9,7 @@ from typing import Dict from typing import List from typing import Tuple from typing import Type +import warnings from _pytest import outcomes from _pytest import reports @@ -22,7 +23,7 @@ from _pytest.pytester import Pytester import pytest -if sys.version_info[:2] < (3, 11): +if sys.version_info < (3, 11): from exceptiongroup import ExceptionGroup @@ -762,6 +763,73 @@ def test_importorskip_imports_last_module_part() -> None: assert os.path == ospath +class TestImportOrSkipExcType: + """Tests for #11523.""" + + def test_no_warning(self) -> None: + # An attempt on a module which does not exist will raise ModuleNotFoundError, so it will + # be skipped normally and no warning will be issued. + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + with pytest.raises(pytest.skip.Exception): + pytest.importorskip("TestImportOrSkipExcType_test_no_warning") + + assert captured == [] + + def test_import_error_with_warning(self, pytester: Pytester) -> None: + # Create a module which exists and can be imported, however it raises + # ImportError due to some other problem. In this case we will issue a warning + # about the future behavior change. + fn = pytester.makepyfile("raise ImportError('some specific problem')") + pytester.syspathinsert() + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + with pytest.raises(pytest.skip.Exception): + pytest.importorskip(fn.stem) + + [warning] = captured + assert warning.category is pytest.PytestDeprecationWarning + + def test_import_error_suppress_warning(self, pytester: Pytester) -> None: + # Same as test_import_error_with_warning, but we can suppress the warning + # by passing ImportError as exc_type. + fn = pytester.makepyfile("raise ImportError('some specific problem')") + pytester.syspathinsert() + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + with pytest.raises(pytest.skip.Exception): + pytest.importorskip(fn.stem, exc_type=ImportError) + + assert captured == [] + + def test_warning_integration(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + def test_foo(): + pytest.importorskip("warning_integration_module") + """ + ) + pytester.makepyfile( + warning_integration_module=""" + raise ImportError("required library foobar not compiled properly") + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*Module 'warning_integration_module' was found, but when imported by pytest it raised:", + "* ImportError('required library foobar not compiled properly')", + "*1 skipped, 1 warning*", + ] + ) + + def test_importorskip_dev_module(monkeypatch) -> None: try: mod = types.ModuleType("mockmodule") diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 3f1c83f5b..a1511b26d 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1146,7 +1146,7 @@ def test_errors_in_xfail_skip_expressions(pytester: Pytester) -> None: if pypy_version_info is not None and pypy_version_info < (6,): markline = markline[1:] - if sys.version_info[:2] >= (3, 10): + if sys.version_info >= (3, 10): expected = [ "*ERROR*test_nameerror*", "*asd*", diff --git a/testing/test_unittest.py b/testing/test_unittest.py index 9ecb548ee..d726e74d6 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1500,6 +1500,95 @@ def test_do_cleanups_on_teardown_failure(pytester: Pytester) -> None: assert passed == 1 +class TestClassCleanupErrors: + """ + Make sure to show exceptions raised during class cleanup function (those registered + via addClassCleanup()). + + See #11728. + """ + + def test_class_cleanups_failure_in_setup(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 2) + cls.addClassCleanup(cleanup, 1) + raise Exception("fail 0") + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=0, errors=1) + result.stdout.fnmatch_lines( + [ + "*Unittest class cleanup errors *2 sub-exceptions*", + "*Exception: fail 1", + "*Exception: fail 2", + ] + ) + result.stdout.fnmatch_lines( + [ + "* ERROR at setup of MyTestCase.test *", + "E * Exception: fail 0", + ] + ) + + def test_class_cleanups_failure_in_teardown(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 2) + cls.addClassCleanup(cleanup, 1) + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*Unittest class cleanup errors *2 sub-exceptions*", + "*Exception: fail 1", + "*Exception: fail 2", + ] + ) + + def test_class_cleanup_1_failure_in_teardown(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 1) + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of MyTestCase.test*", + "*Exception: fail 1", + ] + ) + + def test_traceback_pruning(pytester: Pytester) -> None: """Regression test for #9610 - doesn't crash during traceback pruning.""" pytester.makepyfile(