Skip to content

Commit 4eb8b6d

Browse files
shekhuvermanicoddemusThe-Compiler
authored
Changed importError to ModuleNotFoundError (#12220)
* Changed importError to ModuleNotFoundError * added testing for importorskip * added exc_types parameter in importorskip * Added warning and Test Cases * Improve tests and docs * Improve deprecation docs * Change exc_type to kw only * Apply suggestions from code review Co-authored-by: Florian Bruhin <[email protected]> * Fix check --------- Co-authored-by: Bruno Oliveira <[email protected]> Co-authored-by: Florian Bruhin <[email protected]>
1 parent fafab1d commit 4eb8b6d

File tree

4 files changed

+168
-3
lines changed

4 files changed

+168
-3
lines changed

changelog/11523.improvement.rst

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
:func:`pytest.importorskip` will now issue a warning if the module could be found, but raised :class:`ImportError` instead of :class:`ModuleNotFoundError`.
2+
3+
The warning can be suppressed by passing ``exc_type=ImportError`` to :func:`pytest.importorskip`.
4+
5+
See :ref:`import-or-skip-import-error` for details.

doc/en/deprecations.rst

+35
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,41 @@ Below is a complete list of all pytest features which are considered deprecated.
1919
:class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.
2020

2121

22+
.. _import-or-skip-import-error:
23+
24+
``pytest.importorskip`` default behavior regarding :class:`ImportError`
25+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26+
27+
.. deprecated:: 8.2
28+
29+
Traditionally :func:`pytest.importorskip` will capture :class:`ImportError`, with the original intent being to skip
30+
tests where a dependent module is not installed, for example testing with different dependencies.
31+
32+
However some packages might be installed in the system, but are not importable due to
33+
some other issue, for example, a compilation error or a broken installation. In those cases :func:`pytest.importorskip`
34+
would still silently skip the test, but more often than not users would like to see the unexpected
35+
error so the underlying issue can be fixed.
36+
37+
In ``8.2`` the ``exc_type`` parameter has been added, giving users the ability of passing :class:`ModuleNotFoundError`
38+
to skip tests only if the module cannot really be found, and not because of some other error.
39+
40+
Catching only :class:`ModuleNotFoundError` by default (and letting other errors propagate) would be the best solution,
41+
however for backward compatibility, pytest will keep the existing behavior but raise an warning if:
42+
43+
1. The captured exception is of type :class:`ImportError`, and:
44+
2. The user does not pass ``exc_type`` explicitly.
45+
46+
If the import attempt raises :class:`ModuleNotFoundError` (the usual case), then the module is skipped and no
47+
warning is emitted.
48+
49+
This way, the usual cases will keep working the same way, while unexpected errors will now issue a warning, with
50+
users being able to supress the warning by passing ``exc_type=ImportError`` explicitly.
51+
52+
In ``9.0``, the warning will turn into an error, and in ``9.1`` :func:`pytest.importorskip` will only capture
53+
:class:`ModuleNotFoundError` by default and no warnings will be issued anymore -- but users can still capture
54+
:class:`ImportError` by passing it to ``exc_type``.
55+
56+
2257
.. _node-ctor-fspath-deprecation:
2358

2459
``fspath`` argument for Node constructors replaced with ``pathlib.Path``

src/_pytest/outcomes.py

+60-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from typing import Type
1212
from typing import TypeVar
1313

14+
from .warning_types import PytestDeprecationWarning
15+
1416

1517
class OutcomeException(BaseException):
1618
"""OutcomeException and its subclass instances indicate and contain info
@@ -192,7 +194,11 @@ def xfail(reason: str = "") -> NoReturn:
192194

193195

194196
def importorskip(
195-
modname: str, minversion: Optional[str] = None, reason: Optional[str] = None
197+
modname: str,
198+
minversion: Optional[str] = None,
199+
reason: Optional[str] = None,
200+
*,
201+
exc_type: Optional[Type[ImportError]] = None,
196202
) -> Any:
197203
"""Import and return the requested module ``modname``, or skip the
198204
current test if the module cannot be imported.
@@ -205,30 +211,81 @@ def importorskip(
205211
:param reason:
206212
If given, this reason is shown as the message when the module cannot
207213
be imported.
214+
:param exc_type:
215+
The exception that should be captured in order to skip modules.
216+
Must be :py:class:`ImportError` or a subclass.
217+
218+
If the module can be imported but raises :class:`ImportError`, pytest will
219+
issue a warning to the user, as often users expect the module not to be
220+
found (which would raise :class:`ModuleNotFoundError` instead).
221+
222+
This warning can be suppressed by passing ``exc_type=ImportError`` explicitly.
223+
224+
See :ref:`import-or-skip-import-error` for details.
225+
208226
209227
:returns:
210228
The imported module. This should be assigned to its canonical name.
211229
212230
Example::
213231
214232
docutils = pytest.importorskip("docutils")
233+
234+
.. versionadded:: 8.2
235+
236+
The ``exc_type`` parameter.
215237
"""
216238
import warnings
217239

218240
__tracebackhide__ = True
219241
compile(modname, "", "eval") # to catch syntaxerrors
220242

243+
# Until pytest 9.1, we will warn the user if we catch ImportError (instead of ModuleNotFoundError),
244+
# as this might be hiding an installation/environment problem, which is not usually what is intended
245+
# when using importorskip() (#11523).
246+
# In 9.1, to keep the function signature compatible, we just change the code below to:
247+
# 1. Use `exc_type = ModuleNotFoundError` if `exc_type` is not given.
248+
# 2. Remove `warn_on_import` and the warning handling.
249+
if exc_type is None:
250+
exc_type = ImportError
251+
warn_on_import_error = True
252+
else:
253+
warn_on_import_error = False
254+
255+
skipped: Optional[Skipped] = None
256+
warning: Optional[Warning] = None
257+
221258
with warnings.catch_warnings():
222259
# Make sure to ignore ImportWarnings that might happen because
223260
# of existing directories with the same name we're trying to
224261
# import but without a __init__.py file.
225262
warnings.simplefilter("ignore")
263+
226264
try:
227265
__import__(modname)
228-
except ImportError as exc:
266+
except exc_type as exc:
267+
# Do not raise or issue warnings inside the catch_warnings() block.
229268
if reason is None:
230269
reason = f"could not import {modname!r}: {exc}"
231-
raise Skipped(reason, allow_module_level=True) from None
270+
skipped = Skipped(reason, allow_module_level=True)
271+
272+
if warn_on_import_error and not isinstance(exc, ModuleNotFoundError):
273+
lines = [
274+
"",
275+
f"Module '{modname}' was found, but when imported by pytest it raised:",
276+
f" {exc!r}",
277+
"In pytest 9.1 this warning will become an error by default.",
278+
"You can fix the underlying problem, or alternatively overwrite this behavior and silence this "
279+
"warning by passing exc_type=ImportError explicitly.",
280+
"See https://docs.pytest.org/en/stable/deprecations.html#pytest-importorskip-default-behavior-regarding-importerror",
281+
]
282+
warning = PytestDeprecationWarning("\n".join(lines))
283+
284+
if warning:
285+
warnings.warn(warning, stacklevel=2)
286+
if skipped:
287+
raise skipped
288+
232289
mod = sys.modules[modname]
233290
if minversion is None:
234291
return mod

testing/test_runner.py

+68
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import List
1010
from typing import Tuple
1111
from typing import Type
12+
import warnings
1213

1314
from _pytest import outcomes
1415
from _pytest import reports
@@ -762,6 +763,73 @@ def test_importorskip_imports_last_module_part() -> None:
762763
assert os.path == ospath
763764

764765

766+
class TestImportOrSkipExcType:
767+
"""Tests for #11523."""
768+
769+
def test_no_warning(self) -> None:
770+
# An attempt on a module which does not exist will raise ModuleNotFoundError, so it will
771+
# be skipped normally and no warning will be issued.
772+
with warnings.catch_warnings(record=True) as captured:
773+
warnings.simplefilter("always")
774+
775+
with pytest.raises(pytest.skip.Exception):
776+
pytest.importorskip("TestImportOrSkipExcType_test_no_warning")
777+
778+
assert captured == []
779+
780+
def test_import_error_with_warning(self, pytester: Pytester) -> None:
781+
# Create a module which exists and can be imported, however it raises
782+
# ImportError due to some other problem. In this case we will issue a warning
783+
# about the future behavior change.
784+
fn = pytester.makepyfile("raise ImportError('some specific problem')")
785+
pytester.syspathinsert()
786+
787+
with warnings.catch_warnings(record=True) as captured:
788+
warnings.simplefilter("always")
789+
790+
with pytest.raises(pytest.skip.Exception):
791+
pytest.importorskip(fn.stem)
792+
793+
[warning] = captured
794+
assert warning.category is pytest.PytestDeprecationWarning
795+
796+
def test_import_error_suppress_warning(self, pytester: Pytester) -> None:
797+
# Same as test_import_error_with_warning, but we can suppress the warning
798+
# by passing ImportError as exc_type.
799+
fn = pytester.makepyfile("raise ImportError('some specific problem')")
800+
pytester.syspathinsert()
801+
802+
with warnings.catch_warnings(record=True) as captured:
803+
warnings.simplefilter("always")
804+
805+
with pytest.raises(pytest.skip.Exception):
806+
pytest.importorskip(fn.stem, exc_type=ImportError)
807+
808+
assert captured == []
809+
810+
def test_warning_integration(self, pytester: Pytester) -> None:
811+
pytester.makepyfile(
812+
"""
813+
import pytest
814+
def test_foo():
815+
pytest.importorskip("warning_integration_module")
816+
"""
817+
)
818+
pytester.makepyfile(
819+
warning_integration_module="""
820+
raise ImportError("required library foobar not compiled properly")
821+
"""
822+
)
823+
result = pytester.runpytest()
824+
result.stdout.fnmatch_lines(
825+
[
826+
"*Module 'warning_integration_module' was found, but when imported by pytest it raised:",
827+
"* ImportError('required library foobar not compiled properly')",
828+
"*1 skipped, 1 warning*",
829+
]
830+
)
831+
832+
765833
def test_importorskip_dev_module(monkeypatch) -> None:
766834
try:
767835
mod = types.ModuleType("mockmodule")

0 commit comments

Comments
 (0)